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 }