diff --git a/pkg/tfbridge/info.go b/pkg/tfbridge/info.go index fbb966254..1f4ff0fda 100644 --- a/pkg/tfbridge/info.go +++ b/pkg/tfbridge/info.go @@ -318,11 +318,18 @@ func MakeResource(pkg string, mod string, res string) tokens.Type { return tokens.NewTypeToken(modT, tokens.TypeName(res)) } -// BoolRef returns a reference to the bool argument. +// BoolRef returns a reference to the bool argument. Retained for backwards compatibility. Prefer [Ref] for new usage +// and other types like strings. func BoolRef(b bool) *bool { return &b } +// Fluently construct a reference to the argument. This utility function is needed to ease configuring the bridge where +// references are expected instead of plain boolean or string literals. +func Ref[T any](x T) *T { + return &x +} + // StringValue gets a string value from a property map if present, else "" func StringValue(vars resource.PropertyMap, prop resource.PropertyKey) string { val, ok := vars[prop] diff --git a/pkg/tfbridge/info/info.go b/pkg/tfbridge/info/info.go index 94ac1f071..aeb8689ab 100644 --- a/pkg/tfbridge/info/info.go +++ b/pkg/tfbridge/info/info.go @@ -441,9 +441,19 @@ type Schema struct { // a name to override the default when targeting C#; "" uses the default. CSharpName string - // a type to override the default; "" uses the default. + // An optional Pulumi type token to use for the Pulumi type projection of the current property. When unset, the + // default behavior is to generate fresh named Pulumi types as needed to represent the schema. To force the use + // of a known type and avoid generating unnecessary types, use both [Type] and [OmitType]. Type tokens.Type + // Used together with [Type] to omit generating any Pulumi types whatsoever for the current property, and + // instead use the object type identified by the token setup in [Type]. + // + // It is an error to set [OmitType] to true without specifying [Type]. + // + // Experimental. + OmitType bool + // alternative types that can be used instead of the override. AltTypes []tokens.Type @@ -502,6 +512,24 @@ type Schema struct { // whether or not to treat this property as secret Secret *bool + + // Specifies the exact name to use for the generated type. + // + // When generating types for properties, by default Pulumi picks reasonable names based on the property path + // prefix and the name of the property. Use [TypeName] to override this decision when the default names for + // nested properties are too long or otherwise undesirable. The choice will further affect the automatically + // generated names for any properties nested under the current one. + // + // Example use: + // + // TypeName: tfbridge.Ref("Visual") + // + // Note that the type name, and not the full token like "aws:quicksight/Visual:Visual" is specified. The token + // will be picked based on the current module ("quicksight" in the above example) where the parent resource or + // data source is found. + // + // Experimental. + TypeName *string } // Config represents a synthetic configuration variable that is Pulumi-only, and not passed to Terraform. diff --git a/pkg/tfgen/generate.go b/pkg/tfgen/generate.go index a81e1e410..85e256a67 100644 --- a/pkg/tfgen/generate.go +++ b/pkg/tfgen/generate.go @@ -342,6 +342,8 @@ type propertyType struct { nestedType tokens.Type altTypes []tokens.Type asset *tfbridge.AssetTranslation + + typeName *string } func (g *Generator) Sink() diag.Sink { @@ -353,6 +355,9 @@ func (g *Generator) makePropertyType(typePath paths.TypePath, entityDocs entityDocs) (*propertyType, error) { t := &propertyType{} + if info != nil { + t.typeName = info.TypeName + } var elemInfo *tfbridge.SchemaInfo if info != nil { @@ -456,10 +461,23 @@ func getDocsFromSchemaMap(key string, schemaMap shim.SchemaMap) string { func (g *Generator) makeObjectPropertyType(typePath paths.TypePath, res shim.Resource, info *tfbridge.SchemaInfo, out bool, entityDocs entityDocs) (*propertyType, error) { + + // If the user supplied an explicit Type token override, omit generating types and short-circuit. + if info != nil && info.OmitType { + if info.Type == "" { + return nil, fmt.Errorf("Cannot set info.OmitType without also setting info.Type") + } + return &propertyType{typ: info.Type}, nil + } + t := &propertyType{ kind: kindObject, } + if info != nil { + t.typeName = info.TypeName + } + if info != nil { t.typ = info.Type t.nestedType = info.NestedType @@ -549,6 +567,15 @@ func (t *propertyType) equals(other *propertyType) bool { if len(t.properties) != len(other.properties) { return false } + switch { + case t.typeName != nil && other.typeName == nil: + return false + case t.typeName == nil && other.typeName != nil: + return false + case t.typeName != nil && other.typeName != nil && + *t.typeName != *other.typeName: + return false + } for i, p := range t.properties { o := other.properties[i] if p.name != o.name { diff --git a/pkg/tfgen/generate_schema.go b/pkg/tfgen/generate_schema.go index 4b05095c9..c699096c2 100644 --- a/pkg/tfgen/generate_schema.go +++ b/pkg/tfgen/generate_schema.go @@ -120,7 +120,15 @@ func (nt *schemaNestedTypes) declareType(typePath paths.TypePath, declarer decla typ *propertyType, isInput bool) string { // Generate a name for this nested type. - typeName := namePrefix + cases.Title(language.Und, cases.NoLower).String(name) + var typeName string + + if typ.typeName != nil { + // Use an explicit name if provided. + typeName = *typ.typeName + } else { + // Otherwise build one based on the current property name and prefix. + typeName = namePrefix + cases.Title(language.Und, cases.NoLower).String(name) + } // Override the nested type name, if necessary. if typ.nestedType.Name().String() != "" { diff --git a/pkg/tfgen/generate_schema_test.go b/pkg/tfgen/generate_schema_test.go index 27616255e..e36fd0401 100644 --- a/pkg/tfgen/generate_schema_test.go +++ b/pkg/tfgen/generate_schema_test.go @@ -19,10 +19,13 @@ import ( "encoding/json" "fmt" "io" + "runtime" + "sort" "testing" "text/template" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hexops/autogold/v2" csgen "github.com/pulumi/pulumi/pkg/v3/codegen/dotnet" gogen "github.com/pulumi/pulumi/pkg/v3/codegen/go" @@ -35,7 +38,9 @@ import ( bridgetesting "github.com/pulumi/pulumi-terraform-bridge/v3/internal/testing" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge/info" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfgen/internal/testprovider" + sdkv2 "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2" "github.com/pulumi/pulumi-terraform-bridge/v3/unstable/metadata" "github.com/pulumi/pulumi-terraform-bridge/x/muxer" ) @@ -116,6 +121,167 @@ func TestCSharpMiniRandom(t *testing.T) { bridgetesting.AssertEqualsJSONFile(t, "test_data/minirandom-schema-csharp.json", schema) } +// Test the ability to force type sharing. Some of the upstream providers generate very large concrete schemata in Go, +// with TF not being materially affected. The example is inspired by QuickSight types in AWS. In Pulumi the default +// projection is going to generate named types for every instance of the shared schema. This may lead to SDK bloat. Test +// the ability of the provider author to curb the bloat and force an explicit sharing. +func TestTypeSharing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping on Windows due to a test setup issue") + } + + tmpdir := t.TempDir() + barCharVisualSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MinItems: 1, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "nest": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "nested_prop": { + Type: schema.TypeBool, + Optional: true, + }, + }, + }, + }, + }, + }, + } + } + visualsSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + MinItems: 1, + MaxItems: 50, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "bar_chart_visual": barCharVisualSchema(), + "box_plot_visual": barCharVisualSchema(), + }, + }, + } + } + provider := info.Provider{ + Name: "testprov", + P: sdkv2.NewProvider(&schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "testprov_r1": { + Schema: map[string]*schema.Schema{ + "sheets": { + Type: schema.TypeList, + MinItems: 1, + MaxItems: 20, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "visuals": visualsSchema(), + }, + }, + }, + }, + }, + "testprov_r2": { + Schema: map[string]*schema.Schema{ + "x": { + Type: schema.TypeInt, + Optional: true, + }, + "sheets": { + Type: schema.TypeList, + MinItems: 1, + MaxItems: 20, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "y": { + Type: schema.TypeBool, + Optional: true, + }, + "visuals": visualsSchema(), + }, + }, + }, + }, + }, + }, + }), + UpstreamRepoPath: tmpdir, + Resources: map[string]*info.Resource{ + "testprov_r1": { + Tok: "testprov:index:R1", + Fields: map[string]*info.Schema{ + "sheets": { + Elem: &info.Schema{ + Fields: map[string]*info.Schema{ + "visuals": { + Elem: &info.Schema{ + TypeName: tfbridge.Ref("Visual"), + }, + }, + }, + }, + }, + }, + }, + "testprov_r2": { + Tok: "testprov:index:R2", + Fields: map[string]*info.Schema{ + "sheets": { + Elem: &info.Schema{ + Fields: map[string]*info.Schema{ + "visuals": { + Elem: &info.Schema{ + Type: "testprov:index/Visual:Visual", + OmitType: true, + }, + }, + }, + }, + }, + }, + }, + }, + } + + var buf bytes.Buffer + schema, err := GenerateSchema(provider, diag.DefaultSink(&buf, &buf, diag.FormatOptions{ + Color: colors.Never, + })) + require.NoError(t, err) + + t.Logf("%s", buf.String()) + + keys := []string{} + for k := range schema.Types { + keys = append(keys, k) + } + sort.Strings(keys) + + // Note that there is only one set of helper types, and they are not prefixed by any of the resource names. + autogold.Expect([]string{ + "testprov:index/R1Sheet:R1Sheet", "testprov:index/R2Sheet:R2Sheet", + "testprov:index/Visual:Visual", + "testprov:index/VisualBarChartVisual:VisualBarChartVisual", + "testprov:index/VisualBarChartVisualNest:VisualBarChartVisualNest", + "testprov:index/VisualBoxPlotVisual:VisualBoxPlotVisual", + "testprov:index/VisualBoxPlotVisualNest:VisualBoxPlotVisualNest", + }).Equal(t, keys) + + bytes, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + autogold.ExpectFile(t, autogold.Raw(string(bytes))) +} + // TestPropertyDocumentationEdits tests that documentation edits are applied to // individual properties. This includes both the property description and // deprecation message. This tests the following workflow diff --git a/pkg/tfgen/testdata/TestTypeSharing.golden b/pkg/tfgen/testdata/TestTypeSharing.golden new file mode 100644 index 000000000..a846f64e7 --- /dev/null +++ b/pkg/tfgen/testdata/TestTypeSharing.golden @@ -0,0 +1,164 @@ +{ + "name": "testprov", + "attribution": "This Pulumi package is based on the [`testprov` Terraform Provider](https://github.com/terraform-providers/terraform-provider-testprov).", + "meta": { + "moduleFormat": "(.*)(?:/[^/]*)" + }, + "language": { + "nodejs": { + "readme": "\u003e This provider is a derived work of the [Terraform Provider](https://github.com/terraform-providers/terraform-provider-testprov)\n\u003e distributed under [MPL 2.0](https://www.mozilla.org/en-US/MPL/2.0/). If you encounter a bug or missing feature,\n\u003e first check the [`pulumi-testprov` repo](/issues); however, if that doesn't turn up anything,\n\u003e please consult the source [`terraform-provider-testprov` repo](https://github.com/terraform-providers/terraform-provider-testprov/issues).", + "compatibility": "tfbridge20", + "disableUnionOutputTypes": true + }, + "python": { + "readme": "\u003e This provider is a derived work of the [Terraform Provider](https://github.com/terraform-providers/terraform-provider-testprov)\n\u003e distributed under [MPL 2.0](https://www.mozilla.org/en-US/MPL/2.0/). If you encounter a bug or missing feature,\n\u003e first check the [`pulumi-testprov` repo](/issues); however, if that doesn't turn up anything,\n\u003e please consult the source [`terraform-provider-testprov` repo](https://github.com/terraform-providers/terraform-provider-testprov/issues).", + "compatibility": "tfbridge20", + "pyproject": {} + } + }, + "config": {}, + "types": { + "testprov:index/R1Sheet:R1Sheet": { + "properties": { + "visuals": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/Visual:Visual" + } + } + }, + "type": "object" + }, + "testprov:index/R2Sheet:R2Sheet": { + "properties": { + "visuals": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/Visual:Visual" + } + }, + "y": { + "type": "boolean" + } + }, + "type": "object" + }, + "testprov:index/Visual:Visual": { + "properties": { + "barChartVisual": { + "$ref": "#/types/testprov:index/VisualBarChartVisual:VisualBarChartVisual" + }, + "boxPlotVisual": { + "$ref": "#/types/testprov:index/VisualBoxPlotVisual:VisualBoxPlotVisual" + } + }, + "type": "object" + }, + "testprov:index/VisualBarChartVisual:VisualBarChartVisual": { + "properties": { + "nest": { + "$ref": "#/types/testprov:index/VisualBarChartVisualNest:VisualBarChartVisualNest" + } + }, + "type": "object" + }, + "testprov:index/VisualBarChartVisualNest:VisualBarChartVisualNest": { + "properties": { + "nestedProp": { + "type": "boolean" + } + }, + "type": "object" + }, + "testprov:index/VisualBoxPlotVisual:VisualBoxPlotVisual": { + "properties": { + "nest": { + "$ref": "#/types/testprov:index/VisualBoxPlotVisualNest:VisualBoxPlotVisualNest" + } + }, + "type": "object" + }, + "testprov:index/VisualBoxPlotVisualNest:VisualBoxPlotVisualNest": { + "properties": { + "nestedProp": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "provider": { + "description": "The provider type for the testprov package. By default, resources use package-wide configuration\nsettings, however an explicit `Provider` instance may be created and passed during resource\nconstruction to achieve fine-grained programmatic control over provider settings. See the\n[documentation](https://www.pulumi.com/docs/reference/programming-model/#providers) for more information.\n" + }, + "resources": { + "testprov:index:R1": { + "properties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R1Sheet:R1Sheet" + } + } + }, + "inputProperties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R1Sheet:R1Sheet" + } + } + }, + "stateInputs": { + "description": "Input properties used for looking up and filtering R1 resources.\n", + "properties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R1Sheet:R1Sheet" + } + } + }, + "type": "object" + } + }, + "testprov:index:R2": { + "properties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R2Sheet:R2Sheet" + } + }, + "x": { + "type": "integer" + } + }, + "inputProperties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R2Sheet:R2Sheet" + } + }, + "x": { + "type": "integer" + } + }, + "stateInputs": { + "description": "Input properties used for looking up and filtering R2 resources.\n", + "properties": { + "sheets": { + "type": "array", + "items": { + "$ref": "#/types/testprov:index/R2Sheet:R2Sheet" + } + }, + "x": { + "type": "integer" + } + }, + "type": "object" + } + } + } +} \ No newline at end of file