Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for generating MR API for Terraform resource's nested single configuration blocks #342

Merged
merged 3 commits into from
Feb 14, 2024
Merged
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
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 {
ulucinar marked this conversation as resolved.
Show resolved Hide resolved
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 {
ulucinar marked this conversation as resolved.
Show resolved Hide resolved
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
Loading