github.com/crossplane/upjet@v1.3.0/pkg/types/field.go (about)

     1  // SPDX-FileCopyrightText: 2023 The Crossplane Authors <https://crossplane.io>
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package types
     6  
     7  import (
     8  	"fmt"
     9  	"go/token"
    10  	"go/types"
    11  	"regexp"
    12  	"sort"
    13  	"strings"
    14  
    15  	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
    16  	"github.com/pkg/errors"
    17  	"k8s.io/utils/ptr"
    18  
    19  	"github.com/crossplane/upjet/pkg"
    20  	"github.com/crossplane/upjet/pkg/config"
    21  	"github.com/crossplane/upjet/pkg/types/comments"
    22  	"github.com/crossplane/upjet/pkg/types/name"
    23  )
    24  
    25  const (
    26  	errFmtInvalidSSAConfiguration = "invalid server-side apply merge strategy configuration: Field schema for %q is of type %q and the specified configuration must only set %q"
    27  	errFmtUnsupportedSSAField     = "cannot configure the server-side apply merge strategy for %q: Configuration can only be specified for lists, sets or maps"
    28  	errFmtMissingListMapKeys      = "server-side apply merge strategy configuration for %q belongs to a list of type map but list map keys configuration is missing"
    29  )
    30  
    31  var parentheses = regexp.MustCompile(`\(([^)]+)\)`)
    32  
    33  // Field represents a field that is built from the Terraform schema.
    34  // It contains the go field related information such as tags, field type, comment.
    35  type Field struct {
    36  	Schema                                   *schema.Schema
    37  	Name                                     name.Name
    38  	Comment                                  *comments.Comment
    39  	TFTag, JSONTag, FieldNameCamel           string
    40  	TerraformPaths, CRDPaths, CanonicalPaths []string
    41  	FieldType                                types.Type
    42  	InitType                                 types.Type
    43  	AsBlocksMode                             bool
    44  	Reference                                *config.Reference
    45  	TransformedName                          string
    46  	SelectorName                             string
    47  	Identifier                               bool
    48  	Required                                 bool
    49  	// Injected is set if this Field is an injected field to the Terraform
    50  	// schema as an object list map key for server-side apply merges.
    51  	Injected bool
    52  }
    53  
    54  // getDocString tries to extract the documentation string for the specified
    55  // field by:
    56  // - first, looking up the field's hierarchical name in
    57  // the dictionary of extracted doc strings
    58  // - second, looking up the terminal name in the same dictionary
    59  // - and third, tries to match hierarchical name with
    60  // the longest suffix matching
    61  func getDocString(cfg *config.Resource, f *Field, tfPath []string) string { //nolint:gocyclo
    62  	hName := f.Name.Snake
    63  	if len(tfPath) > 0 {
    64  		hName = fieldPath(append(tfPath, hName))
    65  	}
    66  	docString := ""
    67  	if cfg.MetaResource != nil {
    68  		// 1st, look up the hierarchical name
    69  		if s, ok := cfg.MetaResource.ArgumentDocs[hName]; ok {
    70  			return getDescription(s)
    71  		}
    72  		lm := 0
    73  		match := ""
    74  		sortedKeys := make([]string, 0, len(cfg.MetaResource.ArgumentDocs))
    75  		for k := range cfg.MetaResource.ArgumentDocs {
    76  			sortedKeys = append(sortedKeys, k)
    77  		}
    78  		sort.Strings(sortedKeys)
    79  		// look up the terminal name
    80  		for _, k := range sortedKeys {
    81  			parts := strings.Split(k, ".")
    82  			if parts[len(parts)-1] == f.Name.Snake {
    83  				lm = len(f.Name.Snake)
    84  				match = k
    85  			}
    86  		}
    87  		if lm == 0 {
    88  			// do longest suffix matching
    89  			for _, k := range sortedKeys {
    90  				if strings.HasSuffix(hName, k) {
    91  					if len(k) > lm {
    92  						lm = len(k)
    93  						match = k
    94  					}
    95  				}
    96  			}
    97  		}
    98  		if lm > 0 {
    99  			docString = getDescription(cfg.MetaResource.ArgumentDocs[match])
   100  		}
   101  	}
   102  	return docString
   103  }
   104  
   105  // NewField returns a constructed Field object.
   106  func NewField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, error) {
   107  	f := &Field{
   108  		Schema:         sch,
   109  		Name:           name.NewFromSnake(snakeFieldName),
   110  		FieldNameCamel: name.NewFromSnake(snakeFieldName).Camel,
   111  		AsBlocksMode:   asBlocksMode,
   112  	}
   113  
   114  	for _, ident := range cfg.ExternalName.IdentifierFields {
   115  		// TODO(turkenh): Could there be a nested identifier field? No, known
   116  		// cases so far but we would need to handle that if/once there is one,
   117  		// which is missing here.
   118  		if ident == snakeFieldName {
   119  			f.Identifier = true
   120  			break
   121  		}
   122  	}
   123  
   124  	for _, required := range cfg.RequiredFields() {
   125  		if required == snakeFieldName {
   126  			f.Required = true
   127  		}
   128  	}
   129  
   130  	var commentText string
   131  	docString := getDocString(cfg, f, tfPath)
   132  	if len(docString) > 0 {
   133  		commentText = docString + "\n"
   134  	}
   135  	commentText += f.Schema.Description
   136  	commentText = pkg.FilterDescription(commentText, pkg.TerraformKeyword)
   137  	comment, err := comments.New(commentText)
   138  	if err != nil {
   139  		return nil, errors.Wrapf(err, "cannot build comment for description: %s", commentText)
   140  	}
   141  	f.Comment = comment
   142  	f.TFTag = fmt.Sprintf("%s,omitempty", f.Name.Snake)
   143  	f.JSONTag = fmt.Sprintf("%s,omitempty", f.Name.LowerCamelComputed)
   144  	f.TransformedName = f.Name.LowerCamelComputed
   145  
   146  	// Terraform paths, e.g. { "lifecycle_rule", "*", "transition", "*", "days" } for https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket#lifecycle_rule
   147  	f.TerraformPaths = append(tfPath, f.Name.Snake) //nolint:gocritic
   148  	// Crossplane paths, e.g. {"lifecycleRule", "*", "transition", "*", "days"}
   149  	f.CRDPaths = append(xpPath, f.Name.LowerCamelComputed) //nolint:gocritic
   150  	// Canonical paths, e.g. {"LifecycleRule", "Transition", "Days"}
   151  	f.CanonicalPaths = append(names[1:], f.Name.Camel) //nolint:gocritic
   152  
   153  	for _, ignoreField := range cfg.LateInitializer.IgnoredFields {
   154  		// Convert configuration input from Terraform path to canonical path
   155  		// Todo(turkenh/muvaf): Replace with a simple string conversion
   156  		//  like GetIgnoredCanonicalFields where we just make each word
   157  		//  between points camel case using names.go utilities. If the path
   158  		//  doesn't match anything, it's no-op in late-init logic anyway.
   159  		if ignoreField == fieldPath(f.TerraformPaths) {
   160  			cfg.LateInitializer.AddIgnoredCanonicalFields(fieldPath(f.CanonicalPaths))
   161  		}
   162  	}
   163  
   164  	fieldType, initType, err := g.buildSchema(f, cfg, names, fieldPath(append(tfPath, snakeFieldName)), r)
   165  	if err != nil {
   166  		return nil, errors.Wrapf(err, "cannot infer type from schema of field %s", f.Name.Snake)
   167  	}
   168  	f.FieldType = fieldType
   169  	f.InitType = initType
   170  
   171  	AddServerSideApplyMarkers(f)
   172  	return f, errors.Wrapf(AddServerSideApplyMarkersFromConfig(f, cfg), "cannot add the server-side apply merge strategy markers for the field")
   173  }
   174  
   175  // AddServerSideApplyMarkers adds server-side apply comment markers to indicate
   176  // that scalar maps and sets can be merged granularly, not replace atomically.
   177  func AddServerSideApplyMarkers(f *Field) {
   178  	// for sensitive fields, we generate secret or secret key references
   179  	if f.Schema.Sensitive {
   180  		return
   181  	}
   182  
   183  	switch f.Schema.Type { //nolint:exhaustive
   184  	case schema.TypeMap:
   185  		// A map should always have an element of type Schema.
   186  		if es, ok := f.Schema.Elem.(*schema.Schema); ok {
   187  			switch es.Type { //nolint:exhaustive
   188  			// We assume scalar types can be granular maps.
   189  			case schema.TypeString, schema.TypeBool, schema.TypeInt, schema.TypeFloat:
   190  				f.Comment.ServerSideApplyOptions.MapType = ptr.To[config.MapType](config.MapTypeGranular)
   191  			}
   192  		}
   193  	case schema.TypeSet:
   194  		if es, ok := f.Schema.Elem.(*schema.Schema); ok {
   195  			switch es.Type { //nolint:exhaustive
   196  			// We assume scalar types can be granular sets.
   197  			case schema.TypeString, schema.TypeBool, schema.TypeInt, schema.TypeFloat:
   198  				f.Comment.ServerSideApplyOptions.ListType = ptr.To[config.ListType](config.ListTypeSet)
   199  			}
   200  		}
   201  	}
   202  	// TODO(negz): Can we reliably add SSA markers for lists of objects? Do we
   203  	// have cases where we're turning a Terraform map of maps into a list of
   204  	// objects with a well-known key that we could merge on?
   205  }
   206  
   207  func setInjectedField(fp, k string, f *Field, s config.MergeStrategy) bool {
   208  	if fp != fmt.Sprintf("%s.%s", k, s.ListMergeStrategy.ListMapKeys.InjectedKey.Key) {
   209  		return false
   210  	}
   211  
   212  	if s.ListMergeStrategy.ListMapKeys.InjectedKey.DefaultValue != "" {
   213  		f.Comment.KubebuilderOptions.Default = ptr.To[string](s.ListMergeStrategy.ListMapKeys.InjectedKey.DefaultValue)
   214  	}
   215  	f.TFTag = "-" // prevent serialization into Terraform configuration
   216  	f.Injected = true
   217  	return true
   218  }
   219  
   220  func AddServerSideApplyMarkersFromConfig(f *Field, cfg *config.Resource) error { //nolint:gocyclo // Easier to follow the logic in a single function
   221  	// for sensitive fields, we generate secret or secret key references
   222  	if f.Schema.Sensitive {
   223  		return nil
   224  	}
   225  	fp := strings.ReplaceAll(strings.Join(f.TerraformPaths, "."), ".*.", ".")
   226  	fp = strings.TrimSuffix(fp, ".*")
   227  	for k, s := range cfg.ServerSideApplyMergeStrategies {
   228  		if setInjectedField(fp, k, f, s) || k != fp {
   229  			continue
   230  		}
   231  		switch f.Schema.Type { //nolint:exhaustive
   232  		case schema.TypeList, schema.TypeSet:
   233  			if s.ListMergeStrategy.MergeStrategy == "" || s.MapMergeStrategy != "" || s.StructMergeStrategy != "" {
   234  				return errors.Errorf(errFmtInvalidSSAConfiguration, k, "list", "ListMergeStrategy")
   235  			}
   236  			f.Comment.ServerSideApplyOptions.ListType = ptr.To[config.ListType](s.ListMergeStrategy.MergeStrategy)
   237  			if s.ListMergeStrategy.MergeStrategy != config.ListTypeMap {
   238  				continue
   239  			}
   240  			f.Comment.ServerSideApplyOptions.ListMapKey = make([]string, 0, len(s.ListMergeStrategy.ListMapKeys.Keys)+1)
   241  			f.Comment.ServerSideApplyOptions.ListMapKey = append(f.Comment.ServerSideApplyOptions.ListMapKey, s.ListMergeStrategy.ListMapKeys.Keys...)
   242  			if s.ListMergeStrategy.ListMapKeys.InjectedKey.Key != "" {
   243  				f.Comment.ServerSideApplyOptions.ListMapKey = append(f.Comment.ServerSideApplyOptions.ListMapKey, s.ListMergeStrategy.ListMapKeys.InjectedKey.Key)
   244  			}
   245  			if len(f.Comment.ServerSideApplyOptions.ListMapKey) == 0 {
   246  				return errors.Errorf(errFmtMissingListMapKeys, k)
   247  			}
   248  		case schema.TypeMap:
   249  			if s.MapMergeStrategy == "" || s.ListMergeStrategy.MergeStrategy != "" || s.StructMergeStrategy != "" {
   250  				return errors.Errorf(errFmtInvalidSSAConfiguration, k, "map", "MapMergeStrategy")
   251  			}
   252  			f.Comment.ServerSideApplyOptions.MapType = ptr.To[config.MapType](s.MapMergeStrategy) // better to have a copy of the strategy
   253  		default:
   254  			// currently the generated APIs do not contain embedded objects, embedded
   255  			// objects are represented as lists of max size 1. However, this may
   256  			// change in the future, i.e., we may decide to generate HCL lists of max
   257  			// size 1 as embedded objects.
   258  			return errors.Errorf(errFmtUnsupportedSSAField, k)
   259  		}
   260  	}
   261  	return nil
   262  }
   263  
   264  // NewSensitiveField returns a constructed sensitive Field object.
   265  func NewSensitiveField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, bool, error) { //nolint:gocyclo
   266  	f, err := NewField(g, cfg, r, sch, snakeFieldName, tfPath, xpPath, names, asBlocksMode)
   267  	if err != nil {
   268  		return nil, false, err
   269  	}
   270  
   271  	if IsObservation(f.Schema) {
   272  		cfg.Sensitive.AddFieldPath(fieldPathWithWildcard(f.TerraformPaths), "status.atProvider."+fieldPathWithWildcard(f.CRDPaths))
   273  		// Drop an observation field from schema if it is sensitive.
   274  		// Data will be stored in connection details secret
   275  		return nil, true, nil
   276  	}
   277  	sfx := "SecretRef"
   278  	switch f.FieldType.(type) {
   279  	case *types.Slice:
   280  		f.CRDPaths[len(f.CRDPaths)-2] = f.CRDPaths[len(f.CRDPaths)-2] + sfx
   281  		cfg.Sensitive.AddFieldPath(fieldPathWithWildcard(f.TerraformPaths), "spec.forProvider."+fieldPathWithWildcard(f.CRDPaths))
   282  	default:
   283  		cfg.Sensitive.AddFieldPath(fieldPathWithWildcard(f.TerraformPaths), "spec.forProvider."+fieldPathWithWildcard(f.CRDPaths)+sfx)
   284  	}
   285  	// todo(turkenh): do we need to support other field types as sensitive?
   286  	if f.FieldType.String() != "string" && f.FieldType.String() != "*string" && f.FieldType.String() != "[]string" &&
   287  		f.FieldType.String() != "[]*string" && f.FieldType.String() != "map[string]string" && f.FieldType.String() != "map[string]*string" {
   288  		return nil, false, fmt.Errorf(`got type %q for field %q, only types "string", "*string", []string, []*string, "map[string]string" and "map[string]*string" supported as sensitive`, f.FieldType.String(), f.FieldNameCamel)
   289  	}
   290  	// Replace a parameter field with secretKeyRef if it is sensitive.
   291  	// If it is an observation field, it will be dropped.
   292  	// Data will be loaded from the referenced secret key.
   293  	f.FieldNameCamel += sfx
   294  
   295  	f.TFTag = "-"
   296  	switch f.FieldType.String() {
   297  	case "string", "*string":
   298  		f.FieldType = typeSecretKeySelector
   299  	case "[]string", "[]*string":
   300  		f.FieldType = types.NewSlice(typeSecretKeySelector)
   301  	case "map[string]string", "map[string]*string":
   302  		f.FieldType = typeSecretReference
   303  	}
   304  	f.TransformedName = name.NewFromCamel(f.FieldNameCamel).LowerCamelComputed
   305  	f.JSONTag = f.TransformedName
   306  	if f.Schema.Optional {
   307  		f.FieldType = types.NewPointer(f.FieldType)
   308  		f.JSONTag += ",omitempty"
   309  	}
   310  
   311  	return f, false, nil
   312  }
   313  
   314  // NewReferenceField returns a constructed reference Field object.
   315  func NewReferenceField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, ref *config.Reference, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, error) {
   316  	f, err := NewField(g, cfg, r, sch, snakeFieldName, tfPath, xpPath, names, asBlocksMode)
   317  	if err != nil {
   318  		return nil, err
   319  	}
   320  	f.Reference = ref
   321  
   322  	f.Comment.Reference = *ref
   323  	f.Schema.Optional = true
   324  
   325  	return f, nil
   326  }
   327  
   328  // AddToResource adds built field to the resource.
   329  func (f *Field) AddToResource(g *Builder, r *resource, typeNames *TypeNames, addToObservation bool) { //nolint:gocyclo
   330  	if f.Comment.UpjetOptions.FieldJSONTag != nil {
   331  		f.JSONTag = *f.Comment.UpjetOptions.FieldJSONTag
   332  	}
   333  
   334  	field := types.NewField(token.NoPos, g.Package, f.FieldNameCamel, f.FieldType, false)
   335  	// if the field is explicitly configured to be added to
   336  	// the Observation type
   337  	if addToObservation {
   338  		r.addObservationField(f, field)
   339  	}
   340  
   341  	if f.Comment.UpjetOptions.FieldTFTag != nil {
   342  		f.TFTag = *f.Comment.UpjetOptions.FieldTFTag
   343  	}
   344  
   345  	// Note(turkenh): We want atProvider to be a superset of forProvider, so
   346  	// we always add the field as an observation field and then add it as a
   347  	// parameter field if it's not an observation (only) field, i.e. parameter.
   348  	//
   349  	// We do this only if tf tag is not set to "-" because otherwise it won't
   350  	// be populated from the tfstate. Injected fields are included in the
   351  	// observation because an associative-list in the spec should also be
   352  	// an associative-list in the observation (status).
   353  	// We also make sure that this field has not already been added to the
   354  	// observation type via an explicit resource configuration.
   355  	// We typically set tf tag to "-" for sensitive fields which were replaced
   356  	// with secretKeyRefs, or for injected fields into the CRD schema,
   357  	// which do not exist in the Terraform schema.
   358  	if (f.TFTag != "-" || f.Injected) && !addToObservation {
   359  		r.addObservationField(f, field)
   360  	}
   361  
   362  	if !IsObservation(f.Schema) {
   363  		if f.AsBlocksMode {
   364  			f.TFTag = strings.TrimSuffix(f.TFTag, ",omitempty")
   365  		}
   366  		r.addParameterField(f, field)
   367  		r.addInitField(f, field, g, typeNames.InitTypeName)
   368  	}
   369  
   370  	if f.Reference != nil {
   371  		r.addReferenceFields(g, typeNames.ParameterTypeName, f, false)
   372  	}
   373  
   374  	// Note(lsviben): All fields are optional because observation fields are
   375  	// optional by default, and forProvider and initProvider fields should
   376  	// be checked through CEL rules.
   377  	// This doesn't count for identifiers and references, which are not
   378  	// mirrored in initProvider.
   379  	if f.isInit() {
   380  		f.Comment.Required = ptr.To(false)
   381  	}
   382  	g.comments.AddFieldComment(typeNames.ParameterTypeName, f.FieldNameCamel, f.Comment.Build())
   383  
   384  	// initProvider and observation fields are always optional.
   385  	f.Comment.Required = nil
   386  	g.comments.AddFieldComment(typeNames.InitTypeName, f.FieldNameCamel, f.Comment.Build())
   387  
   388  	if addToObservation {
   389  		g.comments.AddFieldComment(typeNames.ObservationTypeName, f.FieldNameCamel, f.Comment.CommentWithoutOptions().Build())
   390  	} else {
   391  		// Note(turkenh): We don't want reference resolver to be generated for
   392  		// fields under status.atProvider. So, we don't want reference comments to
   393  		// be added, hence we are unsetting reference on the field comment just
   394  		// before adding it as an observation field.
   395  		f.Comment.Reference = config.Reference{}
   396  		g.comments.AddFieldComment(typeNames.ObservationTypeName, f.FieldNameCamel, f.Comment.Build())
   397  	}
   398  }
   399  
   400  // isInit returns true if the field should be added to initProvider.
   401  // We don't add Identifiers, references or fields which tag is set to
   402  // "-" unless they are injected object list map keys for server-side apply
   403  // merges.
   404  //
   405  // Identifiers as they should not be ignorable or part of init due
   406  // the fact being created for one identifier and then updated for another
   407  // means a different resource could be targeted.
   408  //
   409  // Because of how upjet works, the main.tf file is created and filled
   410  // in the Connect step of the reconciliation. So we merge the initProvider
   411  // and forProvider there and write it to the main.tf file. So fields that are
   412  // not part of terraform are not included in this merge, plus they cant be
   413  // ignored through ignore_changes. References similarly get resolved in
   414  // an earlier step, so they cannot be included as well. Plus probably they
   415  // should also not change for Create and Update steps.
   416  func (f *Field) isInit() bool {
   417  	return !f.Identifier && (f.TFTag != "-" || f.Injected)
   418  }
   419  
   420  func getDescription(s string) string {
   421  	// Remove dash
   422  	s = strings.TrimSpace(s)[strings.Index(s, "-")+1:]
   423  
   424  	// Remove 'Reqiured' || 'Optional' information
   425  	matches := parentheses.FindAllString(s, -1)
   426  	for _, m := range matches {
   427  		if strings.HasPrefix(strings.ToLower(m), "(optional") || strings.HasPrefix(strings.ToLower(m), "(required") {
   428  			s = strings.ReplaceAll(s, m, "")
   429  		}
   430  	}
   431  	return strings.TrimSpace(s)
   432  }