...

Source file src/github.com/GoogleCloudPlatform/k8s-config-connector/pkg/crd/crdgeneration/tf2crdgeneration.go

Documentation: github.com/GoogleCloudPlatform/k8s-config-connector/pkg/crd/crdgeneration

     1  // Copyright 2022 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package crdgeneration
    16  
    17  import (
    18  	"fmt"
    19  	"strings"
    20  
    21  	corekccv1alpha1 "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/apis/core/v1alpha1"
    22  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/crd/crdgeneration/crdboilerplate"
    23  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/k8s"
    24  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/krmtotf"
    25  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/text"
    26  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/util/slice"
    27  
    28  	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
    29  	"github.com/hashicorp/terraform-provider-google-beta/google-beta"
    30  	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    31  )
    32  
    33  const (
    34  	TF2CRDLabel = "cnrm.cloud.google.com/tf2crd"
    35  )
    36  
    37  func GenerateTF2CRD(sm *corekccv1alpha1.ServiceMapping, resourceConfig *corekccv1alpha1.ResourceConfig) (*apiextensions.CustomResourceDefinition, error) {
    38  	resource := resourceConfig.Name
    39  	p := google.Provider()
    40  	r, ok := p.ResourcesMap[resource]
    41  	if !ok {
    42  		return nil, fmt.Errorf("unknown resource %v", resource)
    43  	}
    44  	s := r.Schema
    45  	specFields := make(map[string]*schema.Schema)
    46  	statusFields := make(map[string]*schema.Schema)
    47  	for k, v := range s {
    48  		if isConfigurableField(v) {
    49  			specFields[k] = v
    50  		} else {
    51  			statusFields[k] = v
    52  		}
    53  	}
    54  	openAPIV3Schema := crdboilerplate.GetOpenAPIV3SchemaSkeleton()
    55  	specJSONSchema := tfObjectSchemaToJSONSchema(specFields)
    56  	statusJSONSchema := tfObjectSchemaToJSONSchema(statusFields)
    57  	removeIgnoredFields(resourceConfig, specJSONSchema, statusJSONSchema)
    58  	removeOverwrittenFields(resourceConfig, specJSONSchema)
    59  	markRequiredLocationalFieldsRequired(resourceConfig, specJSONSchema)
    60  	addResourceIDFieldIfSupported(resourceConfig, specJSONSchema)
    61  	handleHierarchicalReferences(resourceConfig, specJSONSchema)
    62  
    63  	if len(specJSONSchema.Properties) > 0 {
    64  		openAPIV3Schema.Properties["spec"] = *specJSONSchema
    65  		if len(specJSONSchema.Required) > 0 {
    66  			openAPIV3Schema.Required = slice.IncludeString(openAPIV3Schema.Required, "spec")
    67  		}
    68  	}
    69  
    70  	statusJSONSchema, err := k8s.RenameStatusFieldsWithReservedNamesIfResourceNotExcluded(resource, statusJSONSchema)
    71  	if err != nil {
    72  		return nil, fmt.Errorf("error renaming status fields with reserved names for %#v: %v", statusJSONSchema, err)
    73  	}
    74  	for k, v := range statusJSONSchema.Properties {
    75  		openAPIV3Schema.Properties["status"].Properties[k] = v
    76  	}
    77  
    78  	group := strings.ToLower(sm.Spec.Name) + "." + ApiDomain
    79  
    80  	kind := text.SnakeCaseToUpperCamelCase(resource)
    81  	if resourceConfig != nil && resourceConfig.Kind != "" {
    82  		kind = resourceConfig.Kind
    83  	}
    84  	crd := GetCustomResourceDefinition(kind, group, sm.GetVersionFor(resourceConfig), openAPIV3Schema, TF2CRDLabel)
    85  	if resourceConfig.AutoGenerated {
    86  		// All the auto-generated Terraform-based CRDs are in Alpha stability.
    87  		crd.ObjectMeta.Labels[k8s.KCCStabilityLabel] = k8s.StabilityLevelAlpha
    88  	} else {
    89  		crd.ObjectMeta.Labels[k8s.KCCStabilityLabel] = k8s.StabilityLevelStable
    90  	}
    91  	return crd, nil
    92  }
    93  
    94  func tfObjectSchemaToJSONSchema(s map[string]*schema.Schema) *apiextensions.JSONSchemaProps {
    95  	jsonSchema := apiextensions.JSONSchemaProps{
    96  		Type:       "object",
    97  		Properties: make(map[string]apiextensions.JSONSchemaProps),
    98  	}
    99  	for k, v := range s {
   100  		key := text.SnakeCaseToLowerCamelCase(k)
   101  		if v.Required {
   102  			jsonSchema.Required = slice.IncludeString(jsonSchema.Required, key)
   103  		}
   104  		js := *tfSchemaToJSONSchema(v)
   105  		description := js.Description
   106  		if description != "" {
   107  			description = ensureEndsInPeriod(description)
   108  		}
   109  		if v.ForceNew {
   110  			description = strings.TrimSpace("Immutable. " + description)
   111  		}
   112  		if v.Deprecated != "" {
   113  			deprecationMsg := ensureEndsInPeriod(fmt.Sprintf("DEPRECATED. %v", v.Deprecated))
   114  			description = strings.TrimSpace(fmt.Sprintf("%v %v", deprecationMsg, description))
   115  		}
   116  		// if the description contains "terraform", ignore the description field
   117  		for _, word := range []string{"terraform", "Terraform"} {
   118  			if !strings.Contains(description, word) {
   119  				continue
   120  			}
   121  			if v.Deprecated != "" {
   122  				panic(fmt.Errorf("about to strip field description since it contains "+
   123  					"the word '%v', but we likely must avoid stripping the "+
   124  					"description entirely since it contains a deprecation message "+
   125  					"that likely should stay included. Suggest changing field's "+
   126  					"description and/or deprecation message to drop the word '%v'. "+
   127  					"Description:\n%v",
   128  					word, word, description))
   129  			}
   130  			description = ""
   131  		}
   132  		js.Description = description
   133  		jsonSchema.Properties[key] = js
   134  	}
   135  	return &jsonSchema
   136  }
   137  
   138  func ensureEndsInPeriod(str string) string {
   139  	if !strings.HasSuffix(str, ".") {
   140  		return str + "."
   141  	}
   142  	return str
   143  }
   144  
   145  func tfSchemaToJSONSchema(tfSchema *schema.Schema) *apiextensions.JSONSchemaProps {
   146  	jsonSchema := apiextensions.JSONSchemaProps{}
   147  	switch tfSchema.Type {
   148  	case schema.TypeBool:
   149  		jsonSchema.Type = "boolean"
   150  	case schema.TypeFloat:
   151  		jsonSchema.Type = "number"
   152  	case schema.TypeInt:
   153  		jsonSchema.Type = "integer"
   154  	case schema.TypeSet:
   155  		// schema.TypeSet is just like schema.TypeList; the validation for no duplicates happens elsewhere.
   156  		fallthrough
   157  	case schema.TypeList:
   158  		jsonSchema.Type = "array"
   159  		switch v := tfSchema.Elem.(type) {
   160  		case *schema.Resource:
   161  			// MaxItems == 1 actually signifies that this is a nested object, and not actually a
   162  			// list, due to limitations of the TF schema type.
   163  			if tfSchema.MaxItems == 1 {
   164  				jsonSchema = *tfObjectSchemaToJSONSchema(v.Schema)
   165  				break
   166  			}
   167  			jsonSchema.Items = &apiextensions.JSONSchemaPropsOrArray{
   168  				Schema: tfObjectSchemaToJSONSchema(v.Schema),
   169  			}
   170  		case *schema.Schema:
   171  			// List of primitives
   172  			jsonSchema.Items = &apiextensions.JSONSchemaPropsOrArray{
   173  				Schema: tfSchemaToJSONSchema(v),
   174  			}
   175  		default:
   176  			panic("could not parse elem attribute of TF list/set schema")
   177  		}
   178  	case schema.TypeMap:
   179  		// schema.TypeMap is only used for basic map[primitive]primitive resources; maps with schemas for the keys
   180  		// are handled by schema.TypeList with MaxItems == 1
   181  		jsonSchema.Type = "object"
   182  		if mapSchema, ok := tfSchema.Elem.(*schema.Schema); ok {
   183  			jsonSchema.AdditionalProperties = &apiextensions.JSONSchemaPropsOrBool{
   184  				Schema: tfSchemaToJSONSchema(mapSchema),
   185  			}
   186  		}
   187  	case schema.TypeString:
   188  		if tfSchema.Sensitive && isConfigurableField(tfSchema) {
   189  			jsonSchema = crdboilerplate.GetSensitiveFieldSchemaBoilerplate()
   190  		} else {
   191  			jsonSchema.Type = "string"
   192  		}
   193  	case schema.TypeInvalid:
   194  		panic(fmt.Errorf("schema type is invalid"))
   195  	default:
   196  		panic(fmt.Errorf("unknown schema type %v", tfSchema.Type))
   197  	}
   198  	jsonSchema.Description = tfSchema.Description
   199  	return &jsonSchema
   200  }
   201  
   202  func removeOverwrittenFields(rc *corekccv1alpha1.ResourceConfig, s *apiextensions.JSONSchemaProps) {
   203  	if rc.MetadataMapping.Name != "" {
   204  		removeField(rc.MetadataMapping.Name, s)
   205  	}
   206  	if rc.MetadataMapping.Labels != "" {
   207  		removeField(rc.MetadataMapping.Labels, s)
   208  	}
   209  	for _, refConfig := range rc.ResourceReferences {
   210  		handleResourceReference(refConfig, s)
   211  	}
   212  	for _, d := range rc.Directives {
   213  		removeField(d, s)
   214  	}
   215  	if !krmtotf.SupportsHierarchicalReferences(rc) {
   216  		// TODO(b/193177782): Delete this if-block once all resources support
   217  		// hierarchical references.
   218  		for _, c := range rc.Containers {
   219  			removeField(c.TFField, s)
   220  		}
   221  	}
   222  }
   223  
   224  func removeIgnoredFields(rc *corekccv1alpha1.ResourceConfig, specJSONSchema, statusJSONSchema *apiextensions.JSONSchemaProps) {
   225  	for _, f := range rc.IgnoredFields {
   226  		removedInSpec := removeFieldIfExist(f, specJSONSchema)
   227  		removedInStatus := removeFieldIfExist(f, statusJSONSchema)
   228  		if removedInSpec && removedInStatus {
   229  			panic(fmt.Errorf("found ignored field %s in both spec and status JSON schema for resource %s", f, rc.Name))
   230  		}
   231  		if !removedInSpec && !removedInStatus {
   232  			panic(fmt.Errorf("cannot find ignored field %s in either spec or status JSON schema for resource %s", f, rc.Name))
   233  		}
   234  	}
   235  }
   236  
   237  // removeFieldIfExist attempts to remove a field from the provided json schema.
   238  // The function is no-op if a field is not found.
   239  // Returns true if a field is found and removed, returns false if a field is not found.
   240  func removeFieldIfExist(f string, s *apiextensions.JSONSchemaProps) bool {
   241  	if !fieldExists(f, s) {
   242  		return false
   243  	}
   244  	removeField(f, s)
   245  	return true
   246  }
   247  
   248  func markRequiredLocationalFieldsRequired(rc *corekccv1alpha1.ResourceConfig, s *apiextensions.JSONSchemaProps) {
   249  	if rc.IDTemplate == "" {
   250  		return
   251  	}
   252  
   253  	locationalFields := []string{"region", "zone", "location"}
   254  	for _, field := range locationalFields {
   255  		// It is assumed that locational fields (region, zone, location) would
   256  		// always be at the base level.
   257  		if _, ok := s.Properties[field]; !ok {
   258  			continue
   259  		}
   260  		if !strings.Contains(rc.IDTemplate, fmt.Sprintf("{{%v}}", field)) {
   261  			continue
   262  		}
   263  		s.Required = slice.IncludeString(s.Required, field)
   264  	}
   265  }
   266  
   267  func handleResourceReference(refConfig corekccv1alpha1.ReferenceConfig, s *apiextensions.JSONSchemaProps) {
   268  	*s = populateReference(strings.Split(refConfig.TFField, "."), refConfig, s)
   269  }
   270  
   271  func populateReference(path []string, refConfig corekccv1alpha1.ReferenceConfig, s *apiextensions.JSONSchemaProps) apiextensions.JSONSchemaProps {
   272  	field := text.SnakeCaseToLowerCamelCase(path[0])
   273  	if len(path) > 1 {
   274  		subSchema := s.Properties[field]
   275  		switch subSchema.Type {
   276  		case "array":
   277  			itemSchema := populateReference(path[1:], refConfig, subSchema.Items.Schema)
   278  			subSchema.Items.Schema = &itemSchema
   279  			return *s
   280  		case "object":
   281  			objSchema := populateReference(path[1:], refConfig, &subSchema)
   282  			s.Properties[field] = objSchema
   283  			return *s
   284  		default:
   285  			panic(fmt.Errorf("error parsing reference %v: cannot iterate into type that is not object or array of objects", path))
   286  		}
   287  	}
   288  
   289  	// Base case; we have found the field representing the reference
   290  	isList := s.Properties[field].Type == "array"
   291  	var refSchema *apiextensions.JSONSchemaProps
   292  	key := field
   293  	if len(refConfig.Types) == 0 {
   294  		if refConfig.Key != "" {
   295  			key = refConfig.Key
   296  			delete(s.Properties, field)
   297  			if slice.StringSliceContains(s.Required, field) {
   298  				s.Required = slice.RemoveStringFromStringSlice(s.Required, field)
   299  				s.Required = slice.IncludeString(s.Required, key)
   300  			}
   301  		}
   302  		refSchema = GetResourceReferenceSchemaFromTypeConfig(refConfig.TypeConfig)
   303  	} else {
   304  		refSchema = &apiextensions.JSONSchemaProps{
   305  			Type:       "object",
   306  			Properties: map[string]apiextensions.JSONSchemaProps{},
   307  		}
   308  		for _, v := range refConfig.Types {
   309  			if v.JSONSchemaType == "" {
   310  				refSchema.Properties[v.Key] = *GetResourceReferenceSchemaFromTypeConfig(v)
   311  			} else {
   312  				refSchema.Properties[v.Key] = apiextensions.JSONSchemaProps{
   313  					Type: v.JSONSchemaType,
   314  				}
   315  			}
   316  		}
   317  	}
   318  
   319  	refSchema.Description = refConfig.Description
   320  
   321  	if isList {
   322  		s.Properties[key] = apiextensions.JSONSchemaProps{
   323  			Type: "array",
   324  			Items: &apiextensions.JSONSchemaPropsOrArray{
   325  				Schema: refSchema,
   326  			},
   327  		}
   328  	} else {
   329  		s.Properties[key] = *refSchema
   330  	}
   331  
   332  	return *s
   333  }
   334  
   335  func getDescriptionForExternalRef(typeConfig corekccv1alpha1.TypeConfig) string {
   336  	targetField := typeConfig.TargetField
   337  	if targetField == "" {
   338  		targetField = "name"
   339  	}
   340  	targetField = text.SnakeCaseToLowerCamelCase(targetField)
   341  	article := text.IndefiniteArticleFor(typeConfig.GVK.Kind)
   342  	if typeConfig.ValueTemplate != "" {
   343  		return fmt.Sprintf(
   344  			"Allowed value: string of the format `%v`, where {{value}} is the `%v` field of %v `%v` resource.",
   345  			typeConfig.ValueTemplate, targetField, article, typeConfig.GVK.Kind,
   346  		)
   347  	}
   348  	return fmt.Sprintf("Allowed value: The `%v` field of %v `%v` resource.", targetField, article, typeConfig.GVK.Kind)
   349  }
   350  
   351  func GetResourceReferenceSchemaFromTypeConfig(typeConfig corekccv1alpha1.TypeConfig) *apiextensions.JSONSchemaProps {
   352  	description := getDescriptionForExternalRef(typeConfig)
   353  	return crdboilerplate.GetResourceReferenceSchemaBoilerplate(description)
   354  }
   355  
   356  func fieldExists(f string, s *apiextensions.JSONSchemaProps) bool {
   357  	path := strings.Split(f, ".")
   358  	return nestedFieldExists(path, s)
   359  }
   360  
   361  func nestedFieldExists(path []string, s *apiextensions.JSONSchemaProps) bool {
   362  	if len(path) == 0 {
   363  		panic("unexpected empty field path")
   364  	}
   365  	// check current level
   366  	field := text.SnakeCaseToLowerCamelCase(path[0])
   367  	subSchema, exists := s.Properties[field]
   368  	if len(path) == 1 {
   369  		return exists
   370  	}
   371  	// go to next level
   372  	switch subSchema.Type {
   373  	case "array":
   374  		return nestedFieldExists(path[1:], subSchema.Items.Schema)
   375  	case "object":
   376  		return nestedFieldExists(path[1:], &subSchema)
   377  	default:
   378  		return false
   379  	}
   380  }
   381  
   382  func removeField(tfField string, s *apiextensions.JSONSchemaProps) {
   383  	*s = removeNestedField(strings.Split(tfField, "."), *s)
   384  }
   385  
   386  func removeNestedField(path []string, s apiextensions.JSONSchemaProps) apiextensions.JSONSchemaProps {
   387  	field := text.SnakeCaseToLowerCamelCase(path[0])
   388  	if len(path) > 1 {
   389  		subSchema := s.Properties[field]
   390  		switch subSchema.Type {
   391  		case "array":
   392  			itemSchema := removeNestedField(path[1:], *subSchema.Items.Schema)
   393  			subSchema.Items.Schema = &itemSchema
   394  		case "object":
   395  			subSchema = removeNestedField(path[1:], subSchema)
   396  		default:
   397  			panic(fmt.Errorf("error parsing field %v: cannot iterate into type that is not object or array of objects", path))
   398  		}
   399  		s.Properties[field] = subSchema
   400  		return s
   401  	}
   402  	delete(s.Properties, field)
   403  	s.Required = slice.RemoveStringFromStringSlice(s.Required, field)
   404  	return s
   405  }
   406  
   407  func isConfigurableField(tfSchema *schema.Schema) bool {
   408  	return tfSchema.Required || tfSchema.Optional
   409  }
   410  
   411  func addResourceIDFieldIfSupported(rc *corekccv1alpha1.ResourceConfig, spec *apiextensions.JSONSchemaProps) {
   412  	if !krmtotf.SupportsResourceIDField(rc) {
   413  		return
   414  	}
   415  
   416  	spec.Properties[k8s.ResourceIDFieldName] = apiextensions.JSONSchemaProps{
   417  		Type:        "string",
   418  		Description: generateResourceIDFieldDescription(rc),
   419  	}
   420  }
   421  
   422  func generateResourceIDFieldDescription(rc *corekccv1alpha1.ResourceConfig) string {
   423  	targetFieldCamelCase := text.SnakeCaseToLowerCamelCase(rc.ResourceID.TargetField)
   424  	isServerGeneratedResourceID := krmtotf.IsResourceIDFieldServerGenerated(rc)
   425  	return GenerateResourceIDFieldDescription(targetFieldCamelCase, isServerGeneratedResourceID)
   426  }
   427  
   428  func handleHierarchicalReferences(rc *corekccv1alpha1.ResourceConfig, spec *apiextensions.JSONSchemaProps) {
   429  	if len(rc.Containers) > 0 {
   430  		// If resource supports resource-level container annotations, mark
   431  		// hierarchical references optional since users can use the annotations
   432  		// to configure the references.
   433  		*spec = *MarkHierarchicalReferencesOptionalButMutuallyExclusive(spec, rc.HierarchicalReferences)
   434  	} else {
   435  		*spec = *MarkHierarchicalReferencesRequiredButMutuallyExclusive(spec, rc.HierarchicalReferences)
   436  	}
   437  }
   438  

View as plain text