Skip to content

Commit

Permalink
Merge pull request #342 from ulucinar/single-nested
Browse files Browse the repository at this point in the history
Add support for generating MR API for Terraform resource's nested single configuration blocks
  • Loading branch information
ulucinar authored Feb 14, 2024
2 parents 780c97f + 47cb5c5 commit 9cb2f6b
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 35 deletions.
119 changes: 108 additions & 11 deletions pkg/config/externalname.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
18 changes: 18 additions & 0 deletions pkg/config/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
27 changes: 22 additions & 5 deletions pkg/types/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand All @@ -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 {
Expand Down
65 changes: 47 additions & 18 deletions pkg/types/conversion/tfjson/tfjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pkg/types/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 9cb2f6b

Please sign in to comment.