Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/bug-fixes-1008.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
component: runtime
kind: bug-fixes
body: Fix codegen for the l1-proxy-index conformance test so map literals, config object classes, and config `any` traversals produce compilable .NET code
time: 2026-05-12T14:30:00.000000+00:00
custom:
PR: "1008"
110 changes: 101 additions & 9 deletions pulumi-language-dotnet/codegen/gen_program.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ type generator struct {
// `Pulumi`, so programs that import `Pulumi` and `Pulumi.Output` hit name collisions. For this reason, we'll import
// the latter as `OutputProvider` rather than `Output`.
namespaceAliases map[string]string
// Variable identifiers whose C# type is `JsonElement` (config vars declared as
// `any`/dynamic and any variables derived from them). Traversal codegen emits
// `.GetProperty("name")` for these instead of property/indexer access.
jsonElementVars map[string]bool
// tokenPackages maps a fully-qualified token to the name of the package that
// owns it. An extension's resource and function tokens live in the base
// provider's namespace, but the SDK roots them under the extension's own name,
Expand Down Expand Up @@ -206,6 +210,7 @@ func GenerateProgramWithOptions(
listInitializer: "new[]",
namespaceAliases: map[string]string{},
tokenPackages: tokenPackages,
jsonElementVars: map[string]bool{},
}

g.Formatter = format.NewFormatter(g)
Expand Down Expand Up @@ -246,6 +251,7 @@ func GenerateProgramWithOptions(
isComponent: true,
listInitializer: "new[]",
namespaceAliases: g.namespaceAliases,
jsonElementVars: map[string]bool{},
}

componentGenerator.Formatter = format.NewFormatter(componentGenerator)
Expand Down Expand Up @@ -613,6 +619,19 @@ func (g *generator) usingStatements(program *pcl.Program) programUsings {
systemUsings := codegen.NewStringSet("System.Linq", "System.Collections.Generic")
pulumiUsings := codegen.NewStringSet()
preambleHelperMethods := codegen.NewStringSet()
// Object-typed config variables emit C# classes annotated with
// [JsonPropertyName] so that PascalCased C# properties round-trip with the
// camelCased JSON keys produced by `pulumi config set`. Config "any" types
// bind to JsonElement (see mainConfigElementType).
if len(collectObjectTypedConfigVariables(program)) > 0 {
systemUsings.Add("System.Text.Json.Serialization")
}
for _, config := range program.ConfigVariables() {
if configUsesDynamic(config.Type()) {
systemUsings.Add("System.Text.Json")
break
}
}
for _, n := range program.Nodes {
if r, isResource := n.(*pcl.Resource); isResource {
pkg, _, _, _ := pcl.DecomposeToken(r.GetToken())
Expand Down Expand Up @@ -755,6 +774,31 @@ func componentOutputElementType(pclType model.Type) string {
}
}

// configUsesDynamic reports whether resolving the config type bottoms out at
// `any`/dynamic in mainConfigElementType — i.e. the program will reference
// JsonElement and needs `using System.Text.Json`.
func configUsesDynamic(pclType model.Type) bool {
pclType = pcl.UnwrapOption(model.ResolveOutputs(pclType))
switch pclType {
case model.BoolType, model.IntType, model.NumberType, model.StringType:
return false
}
switch pclType := pclType.(type) {
case *model.ListType:
return configUsesDynamic(pclType.ElementType)
case *model.MapType:
return configUsesDynamic(pclType.ElementType)
case *model.ObjectType:
for _, prop := range pclType.Properties {
if configUsesDynamic(prop) {
return true
}
}
return false
}
return true
}

func mainConfigElementType(pclType model.Type) string {
pclType = pcl.UnwrapOption(pclType)
switch pclType {
Expand All @@ -775,7 +819,11 @@ func mainConfigElementType(pclType model.Type) string {
elementType := mainConfigElementType(pclType.ElementType)
return fmt.Sprintf("Dictionary<string, %s>", elementType)
default:
return dynamicType
// `JsonSerializer.Deserialize<dynamic>` returns a `JsonElement` boxed
// as `dynamic`, which then refuses dynamic member access. Bind config
// `any` to `JsonElement` directly so that `.GetProperty(...)` traversal
// (emitted elsewhere in codegen) actually compiles.
return "JsonElement"
}
}
}
Expand Down Expand Up @@ -825,24 +873,37 @@ func collectComponentObjectTypedConfigVariables(component *pcl.Component) map[st
return objectTypes
}

// ProgramConfigClassMarker tags an ObjectType that is realized as a top-level
// C# class generated for a main-program config variable. Traversal codegen uses
// this marker to choose property access over indexer access for these types.
type ProgramConfigClassMarker struct {
TypeName string
}

// collectObjectTypedConfigVariables returns the object types in config variables need to be emitted
// as classes in the main program
func collectObjectTypedConfigVariables(program *pcl.Program) map[string]*model.ObjectType {
objectTypes := map[string]*model.ObjectType{}
markClass := func(typeName string, t *model.ObjectType) {
if _, ok := model.GetObjectTypeAnnotation[*ProgramConfigClassMarker](t); !ok {
t.Annotate(&ProgramConfigClassMarker{TypeName: typeName})
}
objectTypes[typeName] = t
}
for _, config := range program.ConfigVariables() {
typeName := cgstrings.UppercaseFirst(makeValidIdentifier(config.Name()))
switch configType := pcl.UnwrapOption(config.Type()).(type) {
case *model.ObjectType:
objectTypes[typeName] = configType
markClass(typeName, configType)
case *model.ListType:
switch elementType := configType.ElementType.(type) {
case *model.ObjectType:
objectTypes[typeName] = elementType
markClass(typeName, elementType)
}
case *model.MapType:
switch elementType := configType.ElementType.(type) {
case *model.ObjectType:
objectTypes[typeName] = elementType
markClass(typeName, elementType)
}
}
}
Expand Down Expand Up @@ -1132,11 +1193,14 @@ func (g *generator) genPostamble(w io.Writer, nodes []pcl.Node) {
objectType := objectTypedConfigVariables[typeName]
g.Fgenf(w, "public class %s\n{\n", typeName)
sortedProperties := slices.Sorted(maps.Keys(objectType.Properties))
for _, propertyName := range sortedProperties {
for _, rawName := range sortedProperties {
g.Indented(func() {
property := objectType.Properties[propertyName]
property := objectType.Properties[rawName]
propertyType := mainConfigElementType(property)
g.Fgenf(w, "%spublic %s %s { get; set; }\n", g.Indent, propertyType, propertyName)
// The wire name (rawName) is the JSON key from config; the C# property
// is PascalCased to follow conventions and match traversal codegen.
g.Fgenf(w, "%s[JsonPropertyName(\"%s\")]\n", g.Indent, rawName)
g.Fgenf(w, "%spublic %s %s { get; set; }\n", g.Indent, propertyType, propertyName(rawName))
})
}
g.Fgenf(w, "}\n\n")
Expand Down Expand Up @@ -2081,7 +2145,7 @@ func computeConfigTypeParam(configName string, configType model.Type) string {
case model.BoolType:
return "bool"
case model.DynamicType:
return "dynamic"
return "JsonElement"
default:
switch complexType := configType.(type) {
case *model.ObjectType:
Expand All @@ -2093,7 +2157,7 @@ func computeConfigTypeParam(configName string, configType model.Type) string {
elementType := computeConfigTypeParam(configName, complexType.ElementType)
return fmt.Sprintf("Dictionary<string, %s>", elementType)
default:
return "dynamic"
return "JsonElement"
}
}
}
Expand Down Expand Up @@ -2165,6 +2229,9 @@ func (g *generator) genConfigVariable(w io.Writer, v *pcl.ConfigVariable) {
g.Indent, name, getOrRequire, getType, typeParam, v.LogicalName())
}
g.Fgenf(w, ";\n")
if configUsesDynamic(v.Type()) {
g.jsonElementVars[name] = true
}
}

func (g *generator) genLocalVariable(w io.Writer, localVariable *pcl.LocalVariable) {
Expand All @@ -2189,6 +2256,31 @@ func (g *generator) genLocalVariable(w io.Writer, localVariable *pcl.LocalVariab
g.Fgenf(w, "%v;\n\n", result)
}
}
// Propagate JsonElement flavor through simple wrappers like `secret(x)`. A
// local variable derived from a JsonElement-typed config still resolves to
// JsonElement at the leaves, so its traversal must use `.GetProperty(...)`.
if g.referencesJSONElementVariable(value) {
g.jsonElementVars[variableName] = true
}
}

// referencesJSONElementVariable reports whether the expression refers to a
// variable already known to hold a JsonElement value.
func (g *generator) referencesJSONElementVariable(expr model.Expression) bool {
if len(g.jsonElementVars) == 0 {
return false
}
found := false
visitor := func(e model.Expression) (model.Expression, hcl.Diagnostics) {
if scope, ok := e.(*model.ScopeTraversalExpression); ok {
if g.jsonElementVars[scope.RootName] {
found = true
}
}
return e, nil
}
_, _ = model.VisitExpression(expr, nil, visitor)
return found
}

func localVariableTypeName(t model.Type) (string, bool) {
Expand Down
60 changes: 56 additions & 4 deletions pulumi-language-dotnet/codegen/gen_program_expressions.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,18 @@ func (g *generator) GenFunctionCallExpression(w io.Writer, expr *model.FunctionC
case "readDir":
g.Fgenf(w, "Directory.GetFiles(%.v).Select(Path.GetFileName)", expr.Args[0])
case "secret":
// A bare object literal (`{ { "k", v } }`) is only valid as a collection
// initializer of a known type. `Output.CreateSecret(...)` is generic, so
// the literal must carry its own type — emit an anonymous record whose
// PascalCased properties line up with the traversal codegen.
if obj, ok := expr.Args[0].(*model.ObjectConsExpression); ok {
if _, hasSchema := g.toSchemaType(obj.Type()); !hasSchema {
g.Fgen(w, "Output.CreateSecret(")
g.genAnonymousRecord(w, obj)
g.Fgen(w, ")")
return
}
}
g.Fgenf(w, "Output.CreateSecret(%v)", expr.Args[0])
case "unsecret":
g.Fgenf(w, "Output.Unsecret(%v)", expr.Args[0])
Expand Down Expand Up @@ -899,6 +911,23 @@ func (g *generator) genRootDirectory(w io.Writer) {
g.Fgenf(w, "Pulumi.Deployment.Instance.RootDirectory")
}

// genAnonymousRecord emits an ObjectConsExpression as a C# anonymous record
// (`new { Key = value, ... }`). This is the standalone-expression form,
// suitable for function arguments where a bare collection initializer would
// not compile. PascalCased field names line up with the traversal codegen,
// which already uppercases the first character of every property name.
func (g *generator) genAnonymousRecord(w io.Writer, expr *model.ObjectConsExpression) {
g.Fgen(w, "new\n")
g.Fgenf(w, "%s{\n", g.Indent)
g.Indented(func() {
for _, item := range expr.Items {
key := objectKey(item)
g.Fgenf(w, "%s%s = %.v,\n", g.Indent, propertyName(key), item.Value)
}
})
g.Fgenf(w, "%s}", g.Indent)
}

func (g *generator) genDictionary(w io.Writer, expr *model.ObjectConsExpression, valueType string) {
g.Fgenf(w, "new Dictionary<string, %s>\n", valueType)
g.Fgenf(w, "%s{\n", g.Indent)
Expand Down Expand Up @@ -1210,8 +1239,13 @@ func findMapType(t model.Type) (*model.MapType, bool) {
return nil, false
}

// genRelativeTraversal emits a traversal sequence. The rootIsJSONElement flag
// indicates whether the root value is a JsonElement (i.e. derived from a config
// `any`). When set, attribute access compiles to `.GetProperty("name")` instead
// of `.PropertyName`.
func (g *generator) genRelativeTraversal(w io.Writer,
traversal hcl.Traversal, parts []model.Traversable, objType *schema.ObjectType, rootIsDictionary bool,
traversal hcl.Traversal, parts []model.Traversable, objType *schema.ObjectType,
rootIsDictionary, rootIsJSONElement bool,
) {
for i, part := range traversal {
var key cty.Value
Expand All @@ -1231,6 +1265,16 @@ func (g *generator) genRelativeTraversal(w io.Writer,
contract.Failf("unexpected traversal part of type %T (%v)", part, part.SourceRange())
}

// The traversal source's static type drives the C# accessor we emit. PCL
// allows `.field` and `["key"]` interchangeably for maps and objects, but
// in C# only MapType sources need indexer syntax. Sources derived from a
// JsonElement-typed config bind to JsonElement, whose API exposes
// `.GetProperty("name")` for attribute access; flow that through nested
// traversals since `GetProperty` itself returns a JsonElement.
sourceType := pcl.UnwrapOption(model.ResolveOutputs(model.GetTraversableType(parts[i])))
_, sourceIsDictionary := sourceType.(*model.MapType)
sourceIsDynamic := rootIsJSONElement && sourceType == model.DynamicType

switch key.Type() {
case cty.String:
if rootIsDictionary && i == 0 {
Expand All @@ -1240,7 +1284,14 @@ func (g *generator) genRelativeTraversal(w io.Writer,
if model.IsOptionalType(model.GetTraversableType(parts[i])) {
g.Fgen(w, "?")
}
g.Fgenf(w, ".%s", propertyName(key.AsString()))
switch {
case sourceIsDynamic:
g.Fgenf(w, ".GetProperty(\"%s\")", key.AsString())
case sourceIsDictionary:
g.Fgenf(w, "[\"%s\"]", key.AsString())
default:
g.Fgenf(w, ".%s", propertyName(key.AsString()))
}
case cty.Number:
idx, _ := key.AsBigFloat().Int64()
g.Fgenf(w, "[%d]", idx)
Expand All @@ -1252,7 +1303,7 @@ func (g *generator) genRelativeTraversal(w io.Writer,

func (g *generator) GenRelativeTraversalExpression(w io.Writer, expr *model.RelativeTraversalExpression) {
g.Fgenf(w, "%.20v", expr.Source)
g.genRelativeTraversal(w, expr.Traversal, expr.Parts, nil, false)
g.genRelativeTraversal(w, expr.Traversal, expr.Parts, nil, false, g.referencesJSONElementVariable(expr.Source))
}

func (g *generator) schemaTypeName(schemaType *schema.ObjectType) string {
Expand Down Expand Up @@ -1374,7 +1425,8 @@ func (g *generator) GenScopeTraversalExpression(w io.Writer, expr *model.ScopeTr
} else if local, ok := expr.Parts[0].(*pcl.LocalVariable); ok {
rootIsDictionary = g.typedDictionaryLocals[local]
}
g.genRelativeTraversal(w, expr.Traversal.SimpleSplit().Rel, expr.Parts, objType, rootIsDictionary)
g.genRelativeTraversal(w, expr.Traversal.SimpleSplit().Rel, expr.Parts, objType,
rootIsDictionary, g.jsonElementVars[expr.RootName])

if isFunctionInvoke && !g.asyncInit && len(expr.Parts) > 1 {
g.Fgenf(w, ")")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Pulumi;
using Random = Pulumi.Random;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Pulumi;
using Aws = Pulumi.Aws;

Expand Down Expand Up @@ -43,7 +44,9 @@

public class Egress
{
[JsonPropertyName("FromPort")]
public int FromPort { get; set; }
[JsonPropertyName("ToPort")]
public int ToPort { get; set; }
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Pulumi;
using Aws = Pulumi.Aws;

Expand Down Expand Up @@ -31,13 +32,17 @@

public class ComplexUserdata
{
public string content { get; set; }
public string path { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
[JsonPropertyName("path")]
public string Path { get; set; }
}

public class Userdata
{
public string content { get; set; }
public string path { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
[JsonPropertyName("path")]
public string Path { get; set; }
}

1 change: 0 additions & 1 deletion pulumi-language-dotnet/language_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ var expectedFailures = map[string]string{
"l1-builtin-can": "#489 codegen not implemented",
"l1-builtin-try": "#490 codegen not implemented",
"l1-keyword-overlap": "#493 update to pulumi 1.50 conformance failure",
"l1-proxy-index": "dotnet build failed",
"l2-resource-provider-inheritance": "No best type found for implicitly-typed array",
"l2-resource-asset-archive": "" +
"The namespace 'Pulumi.AssetArchive' conflicts with the type 'AssetArchive' in 'Pulumi, Version=1.0.0.0",
Expand Down
Loading
Loading