diff --git a/manifest/openapi/foundry_v2.go b/manifest/openapi/foundry_v2.go index 83377262c3..0b0200415d 100644 --- a/manifest/openapi/foundry_v2.go +++ b/manifest/openapi/foundry_v2.go @@ -36,7 +36,6 @@ func NewFoundryFromSpecV2(spec []byte) (Foundry, error) { f := foapiv2{ swagger: &swg, - typeCache: sync.Map{}, gkvIndex: sync.Map{}, //reverse lookup index from GVK to OpenAPI definition IDs recursionDepth: 50, // arbitrarily large number - a type this deep will likely kill Terraform anyway gate: sync.Mutex{}, @@ -57,7 +56,6 @@ type Foundry interface { type foapiv2 struct { swagger *openapi2.T - typeCache sync.Map gkvIndex sync.Map recursionDepth uint64 // a last resort circuit-breaker for run-away recursion - hitting this will make for a bad day gate sync.Mutex @@ -105,7 +103,7 @@ func (f *foapiv2) getTypeByID(id string, h map[string]string, ap tftypes.Attribu return nil, fmt.Errorf("failed to resolve schema: %s", err) } - return getTypeFromSchema(sch, f.recursionDepth, &(f.typeCache), f.swagger.Definitions, ap, h) + return getTypeFromSchema(sch, f.recursionDepth, f.swagger.Definitions, ap, h) } // buildGvkIndex builds the reverse lookup index that associates each GVK diff --git a/manifest/openapi/foundry_v3.go b/manifest/openapi/foundry_v3.go index d0bd40fc01..a68a96edd4 100644 --- a/manifest/openapi/foundry_v3.go +++ b/manifest/openapi/foundry_v3.go @@ -4,6 +4,7 @@ package openapi import ( + "encoding/json" "fmt" "sync" @@ -18,24 +19,39 @@ func NewFoundryFromSpecV3(spec []byte) (Foundry, error) { if err != nil { return nil, err } - return &foapiv3{doc: oapi3}, nil + f := &foapiv3{doc: oapi3} + + err = f.buildGvkIndex() + if err != nil { + return nil, fmt.Errorf("failed to build GVK index when creating new foundry: %w", err) + } + + return f, nil } -func SchemaToSpec(key string, crschema map[string]interface{}) map[string]interface{} { - schema := make(map[string]interface{}) +func CRDSchemaToSpec(gvk schema.GroupVersionKind, crschema map[string]any) map[string]any { + + schema := make(map[string]any) for k, v := range crschema { schema[k] = v } - return map[string]interface{}{ + schema["x-kubernetes-group-version-kind"] = []map[string]any{ + { + "group": gvk.Group, + "version": gvk.Version, + "kind": gvk.Kind, + }, + } + return map[string]any{ "openapi": "3.0", - "info": map[string]interface{}{ + "info": map[string]any{ "title": "CRD schema wrapper", "version": "1.0.0", }, - "paths": map[string]interface{}{}, - "components": map[string]interface{}{ - "schemas": map[string]interface{}{ - key: schema, + "paths": map[string]any{}, + "components": map[string]any{ + "schemas": map[string]any{ + "crd-schema": schema, }, }, } @@ -43,24 +59,49 @@ func SchemaToSpec(key string, crschema map[string]interface{}) map[string]interf type foapiv3 struct { doc *openapi3.T - gate sync.Mutex - typeCache sync.Map + gkvIndex sync.Map } -func (f *foapiv3) GetTypeByGVK(_ schema.GroupVersionKind) (tftypes.Type, map[string]string, error) { - f.gate.Lock() - defer f.gate.Unlock() - +func (f *foapiv3) GetTypeByGVK(gvk schema.GroupVersionKind) (tftypes.Type, map[string]string, error) { var hints map[string]string = make(map[string]string) - ap := tftypes.AttributePath{} - sref := f.doc.Components.Schemas[""] + // the ID string that OpenAPI uses to identify the resource + // e.g. "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + id, ok := f.gkvIndex.Load(gvk) + if !ok { + return nil, nil, fmt.Errorf("resource not found in OpenAPI index") + } + sref := f.doc.Components.Schemas[id.(string)] sch, err := resolveSchemaRef(sref, f.doc.Components.Schemas) if err != nil { - return nil, hints, fmt.Errorf("failed to resolve schema: %s", err) + return nil, hints, fmt.Errorf("failed to resolve schema: %w", err) } - tftype, err := getTypeFromSchema(sch, 50, &(f.typeCache), f.doc.Components.Schemas, ap, hints) + tftype, err := getTypeFromSchema(sch, 50, f.doc.Components.Schemas, tftypes.AttributePath{}, hints) return tftype, hints, err } + +// buildGvkIndex builds the reverse lookup index that associates each GVK +// to its corresponding string key in the swagger.Definitions map +func (f *foapiv3) buildGvkIndex() error { + for did, dRef := range f.doc.Components.Schemas { + def, err := resolveSchemaRef(dRef, f.doc.Components.Schemas) + if err != nil { + return err + } + ex, ok := def.Extensions["x-kubernetes-group-version-kind"] + if !ok { + continue + } + gvk := []schema.GroupVersionKind{} + err = json.Unmarshal(([]byte)(ex.(json.RawMessage)), &gvk) + if err != nil { + return fmt.Errorf("failed to unmarshall GVK from OpenAPI schema extention: %w", err) + } + for i := range gvk { + f.gkvIndex.Store(gvk[i], did) + } + } + return nil +} diff --git a/manifest/openapi/foundry_v3_test.go b/manifest/openapi/foundry_v3_test.go index 4e65186faa..08d6d60240 100644 --- a/manifest/openapi/foundry_v3_test.go +++ b/manifest/openapi/foundry_v3_test.go @@ -6,6 +6,8 @@ package openapi import ( "encoding/json" "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" ) func TestNewFoundryFromSpecV3(t *testing.T) { @@ -20,8 +22,8 @@ func TestNewFoundryFromSpecV3(t *testing.T) { }, "type": "object", } - - spec := SchemaToSpec("com.hashicorp.v1.TestCrd", sampleSchema) + gvk := schema.FromAPIVersionAndKind("hashicorp.com/v1", "TestCrd") + spec := CRDSchemaToSpec(gvk, sampleSchema) j, err := json.Marshal(spec) if err != nil { t.Fatalf("Error: %+v", err) @@ -29,40 +31,49 @@ func TestNewFoundryFromSpecV3(t *testing.T) { f, err := NewFoundryFromSpecV3(j) if err != nil { - t.Fatalf("Error: %+v", err) + t.Fatalf("Error creating foundry: %v", err) + } + + f3, ok := f.(*foapiv3) + if !ok { + t.Fatal("foundry not of expected type") } - if f.(*foapiv3).doc == nil { - t.Fail() + if f3.doc == nil { + t.Fatal("no doc") } - if f.(*foapiv3).doc.Components.Schemas == nil { - t.Fail() + if f3.doc.Components.Schemas == nil { + t.Fatal("no schemas") + } + id, ok := f3.gkvIndex.Load(gvk) + if !ok { + t.Fatal("could not lookup schema id") } - crd, ok := f.(*foapiv3).doc.Components.Schemas["com.hashicorp.v1.TestCrd"] + crd, ok := f3.doc.Components.Schemas[id.(string)] if !ok { - t.Fail() + t.Fatal("CRD schema not found") } if crd == nil || crd.Value == nil { - t.Fail() + t.Fatal("CRD schema empty") } if crd.Value.Type != "object" { - t.Fail() + t.Fatal("CRD type not object") } if crd.Value.Properties == nil { - t.Fail() + t.Fatal("CRD missing properties") } foo, ok := crd.Value.Properties["foo"] if !ok { - t.Fail() + t.Fatal("CRD missing property foo") } if foo.Value.Type != "string" { - t.Fail() + t.Fatal("CRD property foo not a string") } bar, ok := crd.Value.Properties["bar"] if !ok { - t.Fail() + t.Fatal("CRD missing property bar") } if bar.Value.Type != "number" { - t.Fail() + t.Fatal("CRD property bar not a number") } } diff --git a/manifest/openapi/schema.go b/manifest/openapi/schema.go index 3633b96762..50290431a9 100644 --- a/manifest/openapi/schema.go +++ b/manifest/openapi/schema.go @@ -8,24 +8,42 @@ import ( "errors" "fmt" "strings" - "sync" "github.com/getkin/kin-openapi/openapi3" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-provider-kubernetes/manifest" - "github.com/mitchellh/hashstructure" + // "github.com/mitchellh/hashstructure" ) func resolveSchemaRef(ref *openapi3.SchemaRef, defs map[string]*openapi3.SchemaRef) (*openapi3.Schema, error) { + + flattenedRef := ref if ref.Value != nil { - return ref.Value, nil + if len(ref.Value.AllOf) == 1 && + combinationSchemaCount(ref.Value) == 1 && + len(ref.Value.Properties) == 0 && + ref.Value.AdditionalProperties == nil { + + flattenedRef = ref.Value.AllOf[0] + } } - rp := strings.Split(ref.Ref, "/") - sid := rp[len(rp)-1] + sid := flattenedRef.Ref[strings.LastIndex(flattenedRef.Ref, "/")+1:] - nref, ok := defs[sid] + // These are exceptional situations that require non-standard types and that must be + // handled first to not cause runaway recursion. + switch sid { + case "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSONSchemaProps": + fallthrough + case "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1beta1.JSONSchemaProps": + return &openapi3.Schema{Type: ""}, nil + } + + if flattenedRef.Value != nil { + return flattenedRef.Value, nil + } + nref, ok := defs[sid] if !ok { return nil, errors.New("schema not found") } @@ -33,24 +51,10 @@ func resolveSchemaRef(ref *openapi3.SchemaRef, defs map[string]*openapi3.SchemaR return nil, errors.New("nil schema reference") } - // These are exceptional situations that require non-standard types. - switch sid { - case "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSONSchemaProps": - t := openapi3.Schema{ - Type: "", - } - return &t, nil - case "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1beta1.JSONSchemaProps": - t := openapi3.Schema{ - Type: "", - } - return &t, nil - } - return resolveSchemaRef(nref, defs) } -func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync.Map, defs map[string]*openapi3.SchemaRef, ap tftypes.AttributePath, th map[string]string) (tftypes.Type, error) { +func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, defs map[string]*openapi3.SchemaRef, ap tftypes.AttributePath, th map[string]string) (tftypes.Type, error) { if stackdepth == 0 { // this is a hack to overcome the inability to express recursion in tftypes return nil, errors.New("recursion runaway while generating type from OpenAPI spec") @@ -60,8 +64,6 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync return nil, errors.New("cannot convert OpenAPI type (nil)") } - h, herr := hashstructure.Hash(elem, nil) - var t tftypes.Type // Check if attribute type is tagged as 'x-kubernetes-preserve-unknown-fields' in OpenAPI. @@ -78,13 +80,6 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync } } - // check if type is in cache - // HACK: this is temporarily disabled to diagnose a cache corruption issue. - // if herr == nil { - // if t, ok := typeCache.Load(h); ok { - // return t.(tftypes.Type), nil - // } - // } switch elem.Type { case "string": if elem.Format == "int-or-string" { @@ -102,6 +97,10 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync return tftypes.Number, nil case "": + if elem.Format == "int-or-string" { + th[ap.String()] = "io.k8s.apimachinery.pkg.util.intstr.IntOrString" + return tftypes.String, nil + } if xv, ok := elem.Extensions["x-kubernetes-int-or-string"]; ok { xb, err := xv.(json.RawMessage).MarshalJSON() if err != nil { @@ -114,6 +113,57 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync return tftypes.String, nil } } + // Check if it is just a union of primitives, and if this is the case try to translate it to a suitable tftypes primitive. + if len(elem.OneOf) > 0 && + len(elem.OneOf) == combinationSchemaCount(elem) && + len(elem.Properties) == 0 && + elem.AdditionalProperties == nil { + + var stringUnion, intUnion, numberUnion, boolUnion, otherUnion bool + + for _, oneOfRef := range elem.OneOf { + oneOfSchema, err := resolveSchemaRef(oneOfRef, defs) + if err != nil { + return nil, fmt.Errorf("failed to resolve schema for OenOf items: %w", err) + } + oneOfTftype, err := getTypeFromSchema(oneOfSchema, stackdepth-1, defs, ap, th) + if err != nil { + return nil, err + } + + switch { + case oneOfTftype.Is(tftypes.String): + stringUnion = true + case oneOfTftype.Is(tftypes.Number): + if oneOfSchema.Type == "integer" { + intUnion = true + } else { + numberUnion = true + } + case oneOfTftype.Is(tftypes.Bool): + boolUnion = true + default: + otherUnion = true + } + } + + switch { + case otherUnion: // OneOf contained something that couldn't be translated to a fully knowns primitive + break + case stringUnion: // A union of string and any other primitives can always be mapped to string + if intUnion && !numberUnion && !boolUnion { + th[ap.String()] = "io.k8s.apimachinery.pkg.util.intstr.IntOrString" + } + return tftypes.String, nil + case intUnion || numberUnion: // oapi number and integer are both mapped to number + if !boolUnion { + return tftypes.Number, nil + } + case boolUnion: // Only bool + return tftypes.Bool, nil + } + } + return tftypes.DynamicPseudoType, nil // this is where DynamicType is set for when an attribute is tagged as 'x-kubernetes-preserve-unknown-fields' case "array": @@ -121,10 +171,10 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync case elem.Items != nil && elem.AdditionalProperties == nil: // normal array - translates to a tftypes.List it, err := resolveSchemaRef(elem.Items, defs) if err != nil { - return nil, fmt.Errorf("failed to resolve schema for items: %s", err) + return nil, fmt.Errorf("failed to resolve schema for items: %w", err) } aap := ap.WithElementKeyInt(-1) - et, err := getTypeFromSchema(it, stackdepth-1, typeCache, defs, *aap, th) + et, err := getTypeFromSchema(it, stackdepth-1, defs, *aap, th) if err != nil { return nil, err } @@ -133,17 +183,14 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync } else { t = tftypes.List{ElementType: et} } - if herr == nil { - typeCache.Store(h, t) - } return t, nil case elem.AdditionalProperties != nil && elem.Items == nil: // "overriden" array - translates to a tftypes.Tuple it, err := resolveSchemaRef(elem.AdditionalProperties, defs) if err != nil { - return nil, fmt.Errorf("failed to resolve schema for items: %s", err) + return nil, fmt.Errorf("failed to resolve schema for items: %w", err) } aap := ap.WithElementKeyInt(-1) - et, err := getTypeFromSchema(it, stackdepth-1, typeCache, defs, *aap, th) + et, err := getTypeFromSchema(it, stackdepth-1, defs, *aap, th) if err != nil { return nil, err } @@ -160,50 +207,41 @@ func getTypeFromSchema(elem *openapi3.Schema, stackdepth uint64, typeCache *sync for p, v := range elem.Properties { schema, err := resolveSchemaRef(v, defs) if err != nil { - return nil, fmt.Errorf("failed to resolve schema: %s", err) + return nil, fmt.Errorf("failed to resolve schema: %w", err) } aap := ap.WithAttributeName(p) - pType, err := getTypeFromSchema(schema, stackdepth-1, typeCache, defs, *aap, th) + pType, err := getTypeFromSchema(schema, stackdepth-1, defs, *aap, th) if err != nil { return nil, err } atts[p] = pType } t = tftypes.Object{AttributeTypes: atts} - if herr == nil { - typeCache.Store(h, t) - } return t, nil case elem.Properties == nil && elem.AdditionalProperties != nil: // this is how OpenAPI defines associative arrays s, err := resolveSchemaRef(elem.AdditionalProperties, defs) if err != nil { - return nil, fmt.Errorf("failed to resolve schema: %s", err) + return nil, fmt.Errorf("failed to resolve schema: %w", err) } aap := ap.WithElementKeyString("#") - pt, err := getTypeFromSchema(s, stackdepth-1, typeCache, defs, *aap, th) + pt, err := getTypeFromSchema(s, stackdepth-1, defs, *aap, th) if err != nil { return nil, err } t = tftypes.Map{ElementType: pt} - if herr == nil { - typeCache.Store(h, t) - } return t, nil case elem.Properties == nil && elem.AdditionalProperties == nil: // this is a strange case, encountered with io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1 and also io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceSubresourceStatus t = tftypes.DynamicPseudoType - if herr == nil { - typeCache.Store(h, t) - } return t, nil } } - return nil, fmt.Errorf("unknown type: %s", elem.Type) + return nil, fmt.Errorf("unknown type: %w", elem.Type) } func isTypeFullyKnown(t tftypes.Type) bool { @@ -232,3 +270,11 @@ func isTypeFullyKnown(t tftypes.Type) bool { } return true } + +func combinationSchemaCount(schema *openapi3.Schema) int { + notCount := 0 + if schema.Not != nil { + notCount = 1 + } + return notCount + len(schema.AllOf) + len(schema.AnyOf) + len(schema.OneOf) +} diff --git a/manifest/provider/cache.go b/manifest/provider/cache.go index 92312d3c00..31cb2b316e 100644 --- a/manifest/provider/cache.go +++ b/manifest/provider/cache.go @@ -17,3 +17,12 @@ func (c *cache[T]) Get(f func() (T, error)) (T, error) { }) return c.value, c.err } + +type keyedCache[K comparable, V any] struct { + m sync.Map +} + +func (c *keyedCache[K, V]) Get(k K, f func() (V, error)) (V, error) { + ce, _ := c.m.LoadOrStore(k, &cache[V]{}) + return ce.(*cache[V]).Get(f) +} diff --git a/manifest/provider/cache_test.go b/manifest/provider/cache_test.go new file mode 100644 index 0000000000..826481bd9a --- /dev/null +++ b/manifest/provider/cache_test.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "errors" + "testing" +) + +func TestKeyedCache(t *testing.T) { + var kc keyedCache[string, string] + + actualVal, actualErr := kc.Get("a", func() (string, error) { return "1", nil }) + if actualErr != nil { + t.Errorf("unexpected error: %v", actualErr) + } + if actualVal != "1" { + t.Errorf("unexpected value: %s", actualVal) + } + + actualVal, actualErr = kc.Get("a", func() (string, error) { return "2", nil }) + if actualErr != nil { + t.Errorf("unexpected error: %v", actualErr) + } + if actualVal != "1" { + t.Errorf("unexpected value: %s", actualVal) + } + + actualVal, actualErr = kc.Get("b", func() (string, error) { return "2", nil }) + if actualErr != nil { + t.Errorf("unexpected error: %v", actualErr) + } + if actualVal != "2" { + t.Errorf("unexpected value: %s", actualVal) + } + + expectedErr := errors.New("something went wrong") + _, actualErr = kc.Get("c", func() (string, error) { return "", expectedErr }) + if actualErr != expectedErr { + t.Errorf("unexpected or missing error: %v", actualErr) + } +} diff --git a/manifest/provider/clients.go b/manifest/provider/clients.go index 537d0e68cd..417ebbae3b 100644 --- a/manifest/provider/clients.go +++ b/manifest/provider/clients.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-provider-kubernetes/manifest/openapi" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" @@ -77,21 +78,48 @@ func (ps *RawProviderServer) getRestClient() (rest.Interface, error) { // getOAPIv2Foundry returns an interface to request tftype types from an OpenAPIv2 spec func (ps *RawProviderServer) getOAPIv2Foundry() (openapi.Foundry, error) { - return ps.OAPIFoundry.Get(func() (openapi.Foundry, error) { + return ps.oapiV2Foundry.Get(func() (openapi.Foundry, error) { rc, err := ps.getRestClient() if err != nil { - return nil, fmt.Errorf("failed get OpenAPI spec: %s", err) + return nil, fmt.Errorf("failed get OpenAPIv2 spec: %s", err) } - rq := rc.Verb("GET").Timeout(30*time.Second).AbsPath("openapi", "v2") rs, err := rq.DoRaw(context.TODO()) if err != nil { - return nil, fmt.Errorf("failed get OpenAPI spec: %s", err) + return nil, fmt.Errorf("failed get OpenAPIv2 spec: %s", err) } oapif, err := openapi.NewFoundryFromSpecV2(rs) if err != nil { - return nil, fmt.Errorf("failed construct OpenAPI foundry: %s", err) + return nil, fmt.Errorf("failed construct OpenAPIv2 foundry: %s", err) + } + + return oapif, nil + }) +} + +// getOAPIv3Foundry returns an interface to request tftype types from an OpenAPIv3 spec +func (ps *RawProviderServer) getOAPIv3Foundry(gv schema.GroupVersion) (openapi.Foundry, error) { + return ps.oapiV3Foundries.Get(gv, func() (openapi.Foundry, error) { + rc, err := ps.getRestClient() + if err != nil { + return nil, fmt.Errorf("failed get OpenAPIv3 spec: %s", err) + } + gvPrefix := "apis" + // The legacy "v1" spec is under openapi/v3/api/v1, the rest under openapi/v3/apis/{group}/{version} + gvStr := gv.String() + if gvStr == "v1" { + gvPrefix = "api" + } + rq := rc.Verb("GET").Timeout(30*time.Second).AbsPath("openapi", "v3", gvPrefix, gvStr) + rs, err := rq.DoRaw(context.TODO()) + if err != nil { + return nil, fmt.Errorf("failed get OpenAPIv3 spec: %s", err) + } + + oapif, err := openapi.NewFoundryFromSpecV3(rs) + if err != nil { + return nil, fmt.Errorf("failed construct OpenAPIv3 foundry: %s", err) } return oapif, nil diff --git a/manifest/provider/resource.go b/manifest/provider/resource.go index 86b5ce2ebb..6cca511a6b 100644 --- a/manifest/provider/resource.go +++ b/manifest/provider/resource.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-provider-kubernetes/manifest/openapi" @@ -83,66 +84,81 @@ func IsResourceNamespaced(gvk schema.GroupVersionKind, m meta.RESTMapper) (bool, // TFTypeFromOpenAPI generates a tftypes.Type representation of a Kubernetes resource // designated by the supplied GroupVersionKind resource id func (ps *RawProviderServer) TFTypeFromOpenAPI(ctx context.Context, gvk schema.GroupVersionKind, status bool) (tftypes.Type, map[string]string, error) { - var tsch tftypes.Type - var hints map[string]string - oapi, err := ps.getOAPIv2Foundry() - if err != nil { - return nil, hints, fmt.Errorf("cannot get OpenAPI foundry: %s", err) + if oapiV3, err := ps.getOAPIv3Foundry(gvk.GroupVersion()); err == nil { + return getTypeByGVK(gvk, status, oapiV3, "OpenAPI v3") } + + var tfo tftypes.Object + var hints map[string]string + // check if GVK is from a CRD - crdSchema, err := ps.lookUpGVKinCRDs(ctx, gvk) + oapiV2, err := ps.getOAPIv2Foundry() if err != nil { - return nil, hints, fmt.Errorf("failed to look up GVK [%s] among available CRDs: %s", gvk.String(), err) + return nil, hints, fmt.Errorf("cannot get OpenAPI foundry: %w", err) } - if crdSchema != nil { - js, err := json.Marshal(openapi.SchemaToSpec("", crdSchema.(map[string]interface{}))) + + if crdSchema, err := ps.lookUpGVKinCRDs(ctx, gvk); err != nil { + return nil, hints, fmt.Errorf("failed to look up GVK (%v) among available CRDs: %w", gvk, err) + } else if crdSchema != nil { + js, err := json.Marshal(openapi.CRDSchemaToSpec(gvk, crdSchema.(map[string]interface{}))) if err != nil { - return nil, hints, fmt.Errorf("CRD schema fails to marshal into JSON: %s", err) + return nil, hints, fmt.Errorf("CRD schema fails to marshal into JSON: %w", err) } - oapiv3, err := openapi.NewFoundryFromSpecV3(js) + oapiV3, err := openapi.NewFoundryFromSpecV3(js) if err != nil { return nil, hints, err } - tsch, hints, err = oapiv3.GetTypeByGVK(gvk) + tfo, hints, err = getTypeByGVK(gvk, status, oapiV3, "CRD schema") if err != nil { - return nil, hints, fmt.Errorf("failed to generate tftypes for GVK [%s] from CRD schema: %s", gvk.String(), err) + return nil, hints, err } - } - if tsch == nil { - // Not a CRD type - look GVK up in cluster OpenAPI spec - tsch, hints, err = oapi.GetTypeByGVK(gvk) + } else { + // Not a CRD type - look GVK up in cluster OpenAPI v2 spec + tfo, hints, err = getTypeByGVK(gvk, status, oapiV2, "OpenAPI v2") if err != nil { - return nil, hints, fmt.Errorf("cannot get resource type from OpenAPI (%s): %s", gvk.String(), err) + return nil, hints, err } } - // remove "status" attribute from resource type - if tsch.Is(tftypes.Object{}) && !status { - ot := tsch.(tftypes.Object) - atts := make(map[string]tftypes.Type) - for k, t := range ot.AttributeTypes { - if k != "status" { - atts[k] = t - } - } - // types from CRDs only contain specific attributes - // we need to backfill metadata and apiVersion/kind attributes - if _, ok := atts["apiVersion"]; !ok { - atts["apiVersion"] = tftypes.String - } - if _, ok := atts["kind"]; !ok { - atts["kind"] = tftypes.String - } - metaType, _, err := oapi.GetTypeByGVK(openapi.ObjectMetaGVK) - if err != nil { - return nil, hints, fmt.Errorf("failed to generate tftypes for v1.ObjectMeta: %s", err) - } - atts["metadata"] = metaType.(tftypes.Object) - tsch = tftypes.Object{AttributeTypes: atts} + // types from CRDs only contain specific attributes + // we need to backfill metadata and apiVersion/kind attributes + atts := maps.Clone(tfo.AttributeTypes) + if _, ok := atts["apiVersion"]; !ok { + atts["apiVersion"] = tftypes.String + } + if _, ok := atts["kind"]; !ok { + atts["kind"] = tftypes.String + } + metaType, _, err := oapiV2.GetTypeByGVK(openapi.ObjectMetaGVK) + if err != nil { + return nil, hints, fmt.Errorf("failed to generate tftypes for v1.ObjectMeta: %w", err) + } + atts["metadata"] = metaType.(tftypes.Object) + tfo.AttributeTypes = atts + + return tfo, hints, nil +} + +// getTypeByGVK retrieves a terraform type from an OpenAPI fondry given a GVK, verifies it's an Object and +// optionally removes the "status" attribute. +func getTypeByGVK(gvk schema.GroupVersionKind, status bool, oapi openapi.Foundry, source string) (tftypes.Object, map[string]string, error) { + tft, hints, err := oapi.GetTypeByGVK(gvk) + if err != nil { + return tftypes.Object{}, hints, fmt.Errorf("cannot get resource type from %s (%v): %w", source, gvk, err) + } + if !tft.Is(tftypes.Object{}) { + return tftypes.Object{}, hints, fmt.Errorf("did not resolve into an object type (%v)", gvk) + } + tfo := tft.(tftypes.Object) + if !status { + if _, present := tfo.AttributeTypes["status"]; present { + tfo.AttributeTypes = maps.Clone(tfo.AttributeTypes) + delete(tfo.AttributeTypes, "status") + } } - return tsch, hints, nil + return tfo, hints, err } func mapRemoveNulls(in map[string]interface{}) map[string]interface{} { diff --git a/manifest/provider/server.go b/manifest/provider/server.go index 131ace818c..fc0be9d564 100644 --- a/manifest/provider/server.go +++ b/manifest/provider/server.go @@ -14,6 +14,7 @@ import ( "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/install" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes/scheme" @@ -42,7 +43,8 @@ type RawProviderServer struct { discoveryClient cache[discovery.DiscoveryInterface] restMapper cache[meta.RESTMapper] restClient cache[rest.Interface] - OAPIFoundry cache[openapi.Foundry] + oapiV2Foundry cache[openapi.Foundry] + oapiV3Foundries keyedCache[schema.GroupVersion, openapi.Foundry] crds cache[[]unstructured.Unstructured] checkValidCredentialsResult cache[[]*tfprotov5.Diagnostic]