diff --git a/pkg/config/externalname.go b/pkg/config/externalname.go index 7b87884a..cbd34b8e 100644 --- a/pkg/config/externalname.go +++ b/pkg/config/externalname.go @@ -70,34 +70,38 @@ func ParameterAsIdentifier(param string) ExternalName { // TemplatedStringAsIdentifier accepts a template as the shape of the Terraform // ID and lets you provide a field path for the argument you're using as external // name. The available variables you can use in the template are as follows: -// parameters: A tree of parameters that you'd normally see in a Terraform HCL // -// file. You can use TF registry documentation of given resource to -// see what's available. +// parameters: A tree of parameters that you'd normally see in a Terraform HCL +// file. You can use TF registry documentation of given resource to +// see what's available. // // setup.configuration: The Terraform configuration object of the provider. You can -// -// take a look at the TF registry provider configuration object -// to see what's available. Not to be confused with ProviderConfig -// custom resource of the Crossplane provider. +// take a look at the TF registry provider configuration object +// to see what's available. Not to be confused with ProviderConfig +// custom resource of the Crossplane provider. // // setup.client_metadata: The Terraform client metadata available for the provider, -// -// such as the AWS account ID for the AWS provider. +// such as the AWS account ID for the AWS provider. // // external_name: The value of external name annotation of the custom resource. -// -// It is required to use this as part of the template. +// It is required to use this as part of the template. // // The following template functions are available: +// // ToLower: Converts the contents of the pipeline to lower-case +// // ToUpper: Converts the contents of the pipeline to upper-case +// // Please note that it's currently *not* possible to use // the template functions on the .external_name template variable. // Example usages: +// // TemplatedStringAsIdentifier("index_name", "/subscriptions/{{ .setup.configuration.subscription }}/{{ .external_name }}") +// // TemplatedStringAsIdentifier("index_name", "/resource/{{ .external_name }}/static") +// // TemplatedStringAsIdentifier("index_name", "{{ .parameters.cluster_id }}:{{ .parameters.node_id }}:{{ .external_name }}") +// // TemplatedStringAsIdentifier("", "arn:aws:network-firewall:{{ .setup.configuration.region }}:{{ .setup.client_metadata.account_id }}:{{ .parameters.type | ToLower }}-rulegroup/{{ .external_name }}") func TemplatedStringAsIdentifier(nameFieldPath, tmpl string) ExternalName { t, err := template.New("getid").Funcs(template.FuncMap{ @@ -205,3 +209,96 @@ func GetExternalNameFromTemplated(tmpl, val string) (string, error) { //nolint:g } return "", errors.Errorf("unhandled case with template %s and value %s", tmpl, val) } + +// ExternalNameFrom is an ExternalName configuration which uses a parent +// configuration as its base and modifies any of the GetIDFn, +// GetExternalNameFn or SetIdentifierArgumentsFn. This enables us to reuse +// the existing ExternalName configurations with modifications in their +// behaviors via compositions. +type ExternalNameFrom struct { + ExternalName + getIDFn func(GetIDFn, context.Context, string, map[string]any, map[string]any) (string, error) + getExternalNameFn func(GetExternalNameFn, map[string]any) (string, error) + setIdentifierArgumentFn func(SetIdentifierArgumentsFn, map[string]any, string) +} + +// ExternalNameFromOption is an option that modifies the behavior of an +// ExternalNameFrom external-name configuration. +type ExternalNameFromOption func(from *ExternalNameFrom) + +// WithGetIDFn sets the GetIDFn for the ExternalNameFrom configuration. +// The function parameter fn receives the parent ExternalName's GetIDFn, and +// implementations may invoke the parent's GetIDFn via this +// parameter. For the description of the rest of the parameters and return +// values, please see the documentation of GetIDFn. +func WithGetIDFn(fn func(fn GetIDFn, ctx context.Context, externalName string, parameters map[string]any, terraformProviderConfig map[string]any) (string, error)) ExternalNameFromOption { + return func(ec *ExternalNameFrom) { + ec.getIDFn = fn + } +} + +// WithGetExternalNameFn sets the GetExternalNameFn for the ExternalNameFrom +// configuration. The function parameter fn receives the parent ExternalName's +// GetExternalNameFn, and implementations may invoke the parent's +// GetExternalNameFn via this parameter. For the description of the rest +// of the parameters and return values, please see the documentation of +// GetExternalNameFn. +func WithGetExternalNameFn(fn func(fn GetExternalNameFn, tfstate map[string]any) (string, error)) ExternalNameFromOption { + return func(ec *ExternalNameFrom) { + ec.getExternalNameFn = fn + } +} + +// WithSetIdentifierArgumentsFn sets the SetIdentifierArgumentsFn for the +// ExternalNameFrom configuration. The function parameter fn receives the +// parent ExternalName's SetIdentifierArgumentsFn, and implementations may +// invoke the parent's SetIdentifierArgumentsFn via this +// parameter. For the description of the rest of the parameters and return +// values, please see the documentation of SetIdentifierArgumentsFn. +func WithSetIdentifierArgumentsFn(fn func(fn SetIdentifierArgumentsFn, base map[string]any, externalName string)) ExternalNameFromOption { + return func(ec *ExternalNameFrom) { + ec.setIdentifierArgumentFn = fn + } +} + +// NewExternalNameFrom initializes a new ExternalNameFrom with the given parent +// and with the given options. An example configuration that uses a +// TemplatedStringAsIdentifier as its parent (base) and sets a default value +// for the external-name if the external-name is yet not populated is as +// follows: +// +// config.NewExternalNameFrom(config.TemplatedStringAsIdentifier("", "{{ .parameters.type }}/{{ .setup.client_metadata.account_id }}/{{ .external_name }}"), +// +// config.WithGetIDFn(func(fn config.GetIDFn, ctx context.Context, externalName string, parameters map[string]any, terraformProviderConfig map[string]any) (string, error) { +// if externalName == "" { +// externalName = "some random string" +// } +// return fn(ctx, externalName, parameters, terraformProviderConfig) +// })) +func NewExternalNameFrom(parent ExternalName, opts ...ExternalNameFromOption) ExternalName { + ec := &ExternalNameFrom{} + for _, o := range opts { + o(ec) + } + + ec.ExternalName.GetIDFn = func(ctx context.Context, externalName string, parameters map[string]any, terraformProviderConfig map[string]any) (string, error) { + if ec.getIDFn == nil { + return parent.GetIDFn(ctx, externalName, parameters, terraformProviderConfig) + } + return ec.getIDFn(parent.GetIDFn, ctx, externalName, parameters, terraformProviderConfig) + } + ec.ExternalName.GetExternalNameFn = func(tfstate map[string]any) (string, error) { + if ec.getExternalNameFn == nil { + return parent.GetExternalNameFn(tfstate) + } + return ec.getExternalNameFn(parent.GetExternalNameFn, tfstate) + } + ec.ExternalName.SetIdentifierArgumentFn = func(base map[string]any, externalName string) { + if ec.setIdentifierArgumentFn == nil { + parent.SetIdentifierArgumentFn(base, externalName) + return + } + ec.setIdentifierArgumentFn(parent.SetIdentifierArgumentFn, base, externalName) + } + return ec.ExternalName +} diff --git a/pkg/config/resource.go b/pkg/config/resource.go index a9e16344..1023fd9a 100644 --- a/pkg/config/resource.go +++ b/pkg/config/resource.go @@ -532,10 +532,28 @@ func (m SchemaElementOptions) AddToObservation(el string) bool { return m[el] != nil && m[el].AddToObservation } +// SetEmbeddedObject sets the EmbeddedObject for the specified key. +func (m SchemaElementOptions) SetEmbeddedObject(el string) { + if m[el] == nil { + m[el] = &SchemaElementOption{} + } + m[el].EmbeddedObject = true +} + +// EmbeddedObject returns true if the schema element at the specified path +// should be generated as an embedded object. +func (m SchemaElementOptions) EmbeddedObject(el string) bool { + return m[el] != nil && m[el].EmbeddedObject +} + // SchemaElementOption represents configuration options on a schema element. type SchemaElementOption struct { // AddToObservation is set to true if the field represented by // a schema element is to be added to the generated CRD type's // Observation type. AddToObservation bool + // EmbeddedObject is set to true if the field represented by + // a schema element is to be embedded into its parent instead of being + // generated as a single element list. + EmbeddedObject bool } diff --git a/pkg/types/builder.go b/pkg/types/builder.go index 9d11e1be..f61e79e3 100644 --- a/pkg/types/builder.go +++ b/pkg/types/builder.go @@ -206,7 +206,7 @@ func (g *Builder) AddToBuilder(typeNames *TypeNames, r *resource) (*types.Named, return paramType, obsType, initType } -func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, r *resource) (types.Type, types.Type, error) { //nolint:gocyclo +func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, cpath string, r *resource) (types.Type, types.Type, error) { //nolint:gocyclo switch f.Schema.Type { case schema.TypeBool: return types.NewPointer(types.Universe.Lookup("bool").Type()), nil, nil @@ -272,9 +272,16 @@ func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, r // that can go under spec. This check prevents the elimination of fields in parameter type, by checking // whether the schema in observation type has nested parameter (spec) fields. if paramType.Underlying().String() != emptyStruct { - field := types.NewField(token.NoPos, g.Package, f.Name.Camel, types.NewSlice(paramType), false) - r.addParameterField(f, field) - r.addInitField(f, field, g, nil) + var tParam, tInit types.Type + if cfg.SchemaElementOptions.EmbeddedObject(cpath) { + tParam = types.NewPointer(paramType) + tInit = types.NewPointer(initType) + } else { + tParam = types.NewSlice(paramType) + tInit = types.NewSlice(initType) + } + r.addParameterField(f, types.NewField(token.NoPos, g.Package, f.Name.Camel, tParam, false)) + r.addInitField(f, types.NewField(token.NoPos, g.Package, f.Name.Camel, tInit, false), g, nil) } default: if paramType == nil { @@ -285,7 +292,13 @@ func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, r // This check prevents the elimination of fields in observation type, by checking whether the schema in // parameter type has nested observation (status) fields. if obsType.Underlying().String() != emptyStruct { - field := types.NewField(token.NoPos, g.Package, f.Name.Camel, types.NewSlice(obsType), false) + var t types.Type + if cfg.SchemaElementOptions.EmbeddedObject(cpath) { + t = types.NewPointer(obsType) + } else { + t = types.NewSlice(obsType) + } + field := types.NewField(token.NoPos, g.Package, f.Name.Camel, t, false) r.addObservationField(f, field) } } @@ -298,6 +311,10 @@ func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, r return nil, nil, errors.Errorf("element type of %s should be either schema.Resource or schema.Schema", fieldPath(names)) } + // if the singleton list is to be replaced by an embedded object + if cfg.SchemaElementOptions.EmbeddedObject(cpath) { + return types.NewPointer(elemType), types.NewPointer(initElemType), nil + } // NOTE(muvaf): Maps and slices are already pointers, so we don't need to // wrap them even if they are optional. if f.Schema.Type == schema.TypeMap { diff --git a/pkg/types/conversion/tfjson/tfjson.go b/pkg/types/conversion/tfjson/tfjson.go index a8b60102..3fe37df6 100644 --- a/pkg/types/conversion/tfjson/tfjson.go +++ b/pkg/types/conversion/tfjson/tfjson.go @@ -45,12 +45,11 @@ func v2ResourceFromTFJSONSchema(s *tfjson.Schema) *schemav2.Resource { toSchemaMap[k] = tfJSONAttributeToV2Schema(v) } for k, v := range s.Block.NestedBlocks { - // Note(turkenh): We see resource timeouts here as NestingModeSingle. - // However, in plugin SDK resource timeouts is not part of resource - // schema map but set as a separate field. So, we just need to ignore - // here. - // https://github.com/hashicorp/terraform-plugin-sdk/blob/6461ac6e9044a44157c4e2c8aec0f1ab7efc2055/helper/schema/core_schema.go#L315 - if v.NestingMode == tfjson.SchemaNestingModeSingle { + // CRUD timeouts are not part of the generated MR API, + // they cannot be dynamically configured and they are determined by either + // the underlying Terraform resource configuration or the upjet resource + // configuration. Please also see config.Resource.OperationTimeouts. + if k == schemav2.TimeoutsConfigKey { continue } toSchemaMap[k] = tfJSONBlockTypeToV2Schema(v) @@ -95,17 +94,24 @@ func tfJSONBlockTypeToV2Schema(nb *tfjson.SchemaBlockType) *schemav2.Schema { // v2sch.Computed = true } - switch nb.NestingMode { + switch nb.NestingMode { //nolint:exhaustive case tfjson.SchemaNestingModeSet: v2sch.Type = schemav2.TypeSet case tfjson.SchemaNestingModeList: v2sch.Type = schemav2.TypeList case tfjson.SchemaNestingModeMap: v2sch.Type = schemav2.TypeMap - case tfjson.SchemaNestingModeSingle, tfjson.SchemaNestingModeGroup: - panic("unexpected nesting mode: " + nb.NestingMode) + case tfjson.SchemaNestingModeSingle: + v2sch.Type = schemav2.TypeList + v2sch.MinItems = 0 + v2sch.Required = hasRequiredChild(nb) + v2sch.Optional = !v2sch.Required + if v2sch.Required { + v2sch.MinItems = 1 + } + v2sch.MaxItems = 1 default: - panic("unknown nesting mode: " + nb.NestingMode) + panic("unhandled nesting mode: " + nb.NestingMode) } if nb.Block == nil { @@ -121,20 +127,43 @@ func tfJSONBlockTypeToV2Schema(nb *tfjson.SchemaBlockType) *schemav2.Schema { // res.Schema[key] = tfJSONAttributeToV2Schema(attr) } for key, block := range nb.Block.NestedBlocks { - // Note(turkenh): We see resource timeouts here as NestingModeSingle. - // However, in plugin SDK resource timeouts is not part of resource - // schema map but set as a separate field. So, we just need to ignore - // here. - // https://github.com/hashicorp/terraform-plugin-sdk/blob/6461ac6e9044a44157c4e2c8aec0f1ab7efc2055/helper/schema/core_schema.go#L315 - if block.NestingMode == tfjson.SchemaNestingModeSingle { - continue - } + // Please note that unlike the resource-level CRUD timeout configuration + // blocks (as mentioned above), we will generate the timeouts parameters + // for any nested configuration blocks, *if they exist*. + // We can prevent them here, but they are different than the resource's + // top-level CRUD timeouts, so we have opted to generate them. res.Schema[key] = tfJSONBlockTypeToV2Schema(block) } v2sch.Elem = res return v2sch } +// checks whether the given tfjson.SchemaBlockType has any required children. +// Children which are themselves blocks (nested blocks) are +// checked recursively. +func hasRequiredChild(nb *tfjson.SchemaBlockType) bool { + if nb.Block == nil { + return false + } + for _, a := range nb.Block.Attributes { + if a == nil { + continue + } + if a.Required { + return true + } + } + for _, b := range nb.Block.NestedBlocks { + if b == nil { + continue + } + if hasRequiredChild(b) { + return true + } + } + return false +} + func schemaV2TypeFromCtyType(typ cty.Type, schema *schemav2.Schema) error { //nolint:gocyclo configMode := schemav2.SchemaConfigModeAuto diff --git a/pkg/types/field.go b/pkg/types/field.go index 2e6fc2dc..1200b90a 100644 --- a/pkg/types/field.go +++ b/pkg/types/field.go @@ -154,7 +154,7 @@ func NewField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, } } - fieldType, initType, err := g.buildSchema(f, cfg, names, r) + fieldType, initType, err := g.buildSchema(f, cfg, names, fieldPath(append(tfPath, snakeFieldName)), r) if err != nil { return nil, errors.Wrapf(err, "cannot infer type from schema of field %s", f.Name.Snake) }