diff --git a/.gitignore b/.gitignore index 895f9f8..7c20847 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ gitlab/ # go build output (from go build ./cmd/crank etc) /crossplane-diff +/diff # ignore the cluster dir since it's pulled from crossplane/crossplane by earthly cluster/ \ No newline at end of file diff --git a/cmd/diff/client/kubernetes/schema_client.go b/cmd/diff/client/kubernetes/schema_client.go index a576e84..f6adfb5 100644 --- a/cmd/diff/client/kubernetes/schema_client.go +++ b/cmd/diff/client/kubernetes/schema_client.go @@ -7,25 +7,36 @@ import ( "sync" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/core" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + xpextv1 "github.com/crossplane/crossplane/v2/apis/apiextensions/v1" + xpextv2 "github.com/crossplane/crossplane/v2/apis/apiextensions/v2" ) // SchemaClient handles operations related to Kubernetes schemas and CRDs. type SchemaClient interface { // GetCRD gets the CustomResourceDefinition for a given GVK - GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) + + // GetCRDByName gets the CustomResourceDefinition by its name + GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) // IsCRDRequired checks if a GVK requires a CRD IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool - // ValidateResource validates a resource against its schema - ValidateResource(ctx context.Context, resource *un.Unstructured) error + // LoadCRDsFromXRDs converts XRDs to CRDs and caches them + LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error + + // GetAllCRDs returns all cached CRDs (needed for external validation library) + GetAllCRDs() []*extv1.CustomResourceDefinition } // DefaultSchemaClient implements SchemaClient. @@ -37,6 +48,12 @@ type DefaultSchemaClient struct { // Resource type caching resourceTypeMap map[schema.GroupVersionKind]bool resourceMapMu sync.RWMutex + + // CRD caching - consolidated from SchemaValidator + crds []*extv1.CustomResourceDefinition + crdsMu sync.RWMutex + crdByName map[string]*extv1.CustomResourceDefinition // for fast lookup by name + xrdToCRDName map[string]string // maps XRD name to CRD name } // NewSchemaClient creates a new DefaultSchemaClient. @@ -46,22 +63,37 @@ func NewSchemaClient(clients *core.Clients, typeConverter TypeConverter, logger typeConverter: typeConverter, logger: logger, resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), } } // GetCRD gets the CustomResourceDefinition for a given GVK. -func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - // Get the pluralized resource name +func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + // Get the pluralized resource name to construct CRD name resourceName, err := c.typeConverter.GetResourceNameForGVK(ctx, gvk) if err != nil { return nil, errors.Wrapf(err, "cannot determine CRD name for %s", gvk.String()) } - c.logger.Debug("Looking up CRD", "gvk", gvk.String(), "crdName", resourceName) - - // Construct the CRD name using the resource name and group + // Construct the full CRD name crdName := fmt.Sprintf("%s.%s", resourceName, gvk.Group) + // Check cache first + c.crdsMu.RLock() + + if cached, ok := c.crdByName[crdName]; ok { + c.crdsMu.RUnlock() + c.logger.Debug("Using cached CRD", "gvk", gvk.String(), "crdName", crdName) + + return cached, nil + } + + c.crdsMu.RUnlock() + + c.logger.Debug("Looking up CRD", "gvk", gvk.String(), "crdName", resourceName) + // Define the CRD GVR directly to avoid recursion crdGVR := schema.GroupVersionResource{ Group: "apiextensions.k8s.io", @@ -69,8 +101,8 @@ func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersio Resource: "customresourcedefinitions", } - // Fetch the CRD - crd, err := c.dynamicClient.Resource(crdGVR).Get(ctx, crdName, metav1.GetOptions{}) + // Fetch the CRD from cluster + crdObj, err := c.dynamicClient.Resource(crdGVR).Get(ctx, crdName, metav1.GetOptions{}) if err != nil { c.logger.Debug("Failed to get CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) return nil, errors.Wrapf(err, "cannot get CRD %s for %s", crdName, gvk.String()) @@ -78,7 +110,17 @@ func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersio c.logger.Debug("Successfully retrieved CRD", "gvk", gvk.String(), "crdName", resourceName) - return crd, nil + // Convert to typed CRD + crdTyped := &extv1.CustomResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crdTyped); err != nil { + c.logger.Debug("Error converting CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) + return nil, errors.Wrapf(err, "cannot convert CRD %s to typed", crdName) + } + + // Add to cache + c.addCRD(crdTyped) + + return crdTyped, nil } // IsCRDRequired checks if a GVK requires a CRD. @@ -135,13 +177,6 @@ func (c *DefaultSchemaClient) IsCRDRequired(ctx context.Context, gvk schema.Grou return true } -// ValidateResource validates a resource against its schema. -func (c *DefaultSchemaClient) ValidateResource(_ context.Context, resource *un.Unstructured) error { - // This would use OpenAPI validation - simplified for now - c.logger.Debug("Validating resource", "kind", resource.GetKind(), "name", resource.GetName()) - return nil -} - // Helper to cache resource type requirements. func (c *DefaultSchemaClient) cacheResourceType(gvk schema.GroupVersionKind, requiresCRD bool) { c.resourceMapMu.Lock() @@ -149,3 +184,215 @@ func (c *DefaultSchemaClient) cacheResourceType(gvk schema.GroupVersionKind, req c.resourceTypeMap[gvk] = requiresCRD } + +// extractGVKsFromXRDs extracts GVKs from multiple XRDs. This is a pure function with no side effects. +func extractGVKsFromXRDs(xrds []*un.Unstructured) ([]schema.GroupVersionKind, error) { + var allGVKs []schema.GroupVersionKind + + for _, xrd := range xrds { + gvks, err := extractGVKsFromXRD(xrd) + if err != nil { + return nil, errors.Wrapf(err, "failed to extract GVKs from XRD %s", xrd.GetName()) + } + + allGVKs = append(allGVKs, gvks...) + } + + return allGVKs, nil +} + +// extractGVKsFromXRD extracts all GroupVersionKinds from an XRD using strongly-typed conversion. +// This method handles both v1 and v2 XRDs and leverages Kubernetes runtime conversion. +func extractGVKsFromXRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + apiVersion := xrd.GetAPIVersion() + + switch apiVersion { + case "apiextensions.crossplane.io/v1": + return extractGVKsFromV1XRD(xrd) + case "apiextensions.crossplane.io/v2": + return extractGVKsFromV2XRD(xrd) + default: + return nil, errors.Errorf("unsupported XRD apiVersion %s in XRD %s", apiVersion, xrd.GetName()) + } +} + +// extractGVKsFromV1XRD extracts GVKs from a v1 XRD using strongly-typed conversion. +func extractGVKsFromV1XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + typedXRD := &xpextv1.CompositeResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { + return nil, errors.Wrapf(err, "cannot convert XRD %s to v1 typed object", xrd.GetName()) + } + + // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid + gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) + for _, version := range typedXRD.Spec.Versions { + gvks = append(gvks, schema.GroupVersionKind{ + Group: typedXRD.Spec.Group, + Version: version.Name, + Kind: typedXRD.Spec.Names.Kind, + }) + } + + return gvks, nil +} + +// extractGVKsFromV2XRD extracts GVKs from a v2 XRD using strongly-typed conversion. +func extractGVKsFromV2XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + typedXRD := &xpextv2.CompositeResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { + return nil, errors.Wrapf(err, "cannot convert XRD %s to v2 typed object", xrd.GetName()) + } + + // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid + gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) + for _, version := range typedXRD.Spec.Versions { + gvks = append(gvks, schema.GroupVersionKind{ + Group: typedXRD.Spec.Group, + Version: version.Name, + Kind: typedXRD.Spec.Names.Kind, + }) + } + + return gvks, nil +} + +// LoadCRDsFromXRDs fetches corresponding CRDs from the cluster for the given XRDs and caches them. +// Instead of converting XRDs to CRDs, this method fetches the actual CRDs that should already +// exist in the cluster since the Crossplane control plane manages both XRDs and their corresponding CRDs. +func (c *DefaultSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { + c.logger.Debug("Loading CRDs from cluster for XRDs", "xrdCount", len(xrds)) + + if len(xrds) == 0 { + c.logger.Debug("No XRDs provided, nothing to load") + return nil + } + + // Extract GVKs from XRDs using the pure function + gvks, err := extractGVKsFromXRDs(xrds) + if err != nil { + return err // Error already wrapped with context from ExtractGVKsFromXRDs + } + + // Build XRD-to-CRD name mappings for later use + xrdToCRDMappings := make(map[string]string) // XRD name -> CRD name + + for _, xrd := range xrds { + // Extract the CRD name from XRD spec (format: {plural}.{group}) + group, _, _ := un.NestedString(xrd.Object, "spec", "group") + + plural, _, _ := un.NestedString(xrd.Object, "spec", "names", "plural") + if group != "" && plural != "" { + crdName := plural + "." + group + xrdName := xrd.GetName() + xrdToCRDMappings[xrdName] = crdName + c.logger.Debug("Mapped XRD to CRD", "xrdName", xrdName, "crdName", crdName) + } + } + + // Load CRDs from the extracted GVKs + err = c.loadCRDsFromGVKs(ctx, gvks) + if err != nil { + return err // Error already wrapped with context from LoadCRDsFromGVKs + } + + // Store XRD-to-CRD name mappings + c.crdsMu.Lock() + + for xrdName, crdName := range xrdToCRDMappings { + c.xrdToCRDName[xrdName] = crdName + } + + c.crdsMu.Unlock() + + c.logger.Debug("Successfully stored XRD-to-CRD mappings", "count", len(xrdToCRDMappings)) + + return nil +} + +// loadCRDsFromGVKs fetches CRDs from the cluster for the given GVKs and caches them. +// This method fetches the actual CRDs from the cluster for each provided GVK. +func (c *DefaultSchemaClient) loadCRDsFromGVKs(ctx context.Context, gvks []schema.GroupVersionKind) error { + c.logger.Debug("Loading CRDs from cluster for GVKs", "gvkCount", len(gvks)) + + if len(gvks) == 0 { + c.logger.Debug("No GVKs provided, nothing to load") + return nil + } + + // TODO: Consider parallel fetching of CRDs to improve performance for large numbers of GVKs. + // This could significantly speed up initialization when dealing with many XRDs. + // For now, we fetch sequentially to keep the implementation simple. + + // Fetch CRDs from cluster for each GVK - fail fast if any CRD is missing + // Per repository guidelines: never continue in a degraded state + fetchedCRDs := make([]*extv1.CustomResourceDefinition, 0, len(gvks)) + + for _, gvk := range gvks { + crd, err := c.GetCRD(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to fetch required CRD for GVK", "gvk", gvk.String(), "error", err) + return errors.Wrapf(err, "cannot fetch required CRD for %s", gvk.String()) + } + + fetchedCRDs = append(fetchedCRDs, crd) + } + + c.logger.Debug("Successfully fetched all required CRDs from cluster", "count", len(fetchedCRDs)) + + return nil +} + +// GetCRDByName gets a CRD by its name from the cache. +// If the name is not found directly, it will also check if it's an XRD name +// that maps to a different CRD name (e.g., claim XRDs). +func (c *DefaultSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { + c.crdsMu.RLock() + defer c.crdsMu.RUnlock() + + // First, try direct lookup by CRD name + if crd, exists := c.crdByName[name]; exists { + return crd, nil + } + + // If not found, check if this is an XRD name that maps to a different CRD name + if crdName, exists := c.xrdToCRDName[name]; exists { + if crd, exists := c.crdByName[crdName]; exists { + c.logger.Debug("Found CRD for XRD via name mapping", "xrdName", name, "crdName", crdName) + return crd, nil + } + } + + return nil, errors.Errorf("CRD with name %s not found in cache", name) +} + +// GetAllCRDs returns all cached CRDs. +func (c *DefaultSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { + c.crdsMu.RLock() + defer c.crdsMu.RUnlock() + + // Return a copy to prevent external modification + result := make([]*extv1.CustomResourceDefinition, len(c.crds)) + copy(result, c.crds) + + return result +} + +// addCRD adds a CRD to the cache. +func (c *DefaultSchemaClient) addCRD(crd *extv1.CustomResourceDefinition) { + c.crdsMu.Lock() + defer c.crdsMu.Unlock() + + // Check if already cached to avoid duplicates + if _, exists := c.crdByName[crd.Name]; exists { + c.logger.Debug("CRD already in cache, skipping", "crdName", crd.Name) + return + } + + // Add to slice + c.crds = append(c.crds, crd) + + // Add to name lookup map + c.crdByName[crd.Name] = crd + + c.logger.Debug("Added CRD to cache", "crdName", crd.Name) +} diff --git a/cmd/diff/client/kubernetes/schema_client_test.go b/cmd/diff/client/kubernetes/schema_client_test.go index f762c8e..7482d4d 100644 --- a/cmd/diff/client/kubernetes/schema_client_test.go +++ b/cmd/diff/client/kubernetes/schema_client_test.go @@ -7,6 +7,7 @@ import ( tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -17,6 +18,12 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) +const ( + testXResourceKind = "XResource" + testXResourcePlural = "xresources" + testExampleOrgGroup = "example.org" +) + var _ SchemaClient = (*tu.MockSchemaClient)(nil) func TestSchemaClient_IsCRDRequired(t *testing.T) { @@ -60,17 +67,16 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { setupConverter: func() TypeConverter { // For custom resources, our converter should return a successful resource name return tu.NewMockTypeConverter(). - WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { - if gvk.Group == "example.org" && gvk.Version == "v1" && gvk.Kind == "XResource" { - return "xresources", nil - } - return "", errors.New("unexpected GVK in test") - }).Build() + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() }, gvk: schema.GroupVersionKind{ - Group: "example.org", + Group: testExampleOrgGroup, Version: "v1", - Kind: "XResource", + Kind: testXResourceKind, }, want: true, // Custom resource should require a CRD }, @@ -79,12 +85,11 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { setupConverter: func() TypeConverter { // For apiextensions resources, our converter should return a successful resource name return tu.NewMockTypeConverter(). - WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { - if gvk.Group == "apiextensions.k8s.io" && gvk.Version == "v1" && gvk.Kind == "CustomResourceDefinition" { - return "customresourcedefinitions", nil - } - return "", errors.New("unexpected GVK in test") - }).Build() + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }, "customresourcedefinitions").Build() }, gvk: schema.GroupVersionKind{ Group: "apiextensions.k8s.io", @@ -98,12 +103,11 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { setupConverter: func() TypeConverter { // For networking.k8s.io resources, our converter should return a successful resource name return tu.NewMockTypeConverter(). - WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { - if gvk.Group == "networking.k8s.io" && gvk.Version == "v1" && gvk.Kind == "NetworkPolicy" { - return "networkpolicies", nil - } - return "", errors.New("unexpected GVK in test") - }).Build() + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: "networking.k8s.io", + Version: "v1", + Kind: "NetworkPolicy", + }, "networkpolicies").Build() }, gvk: schema.GroupVersionKind{ Group: "networking.k8s.io", @@ -122,9 +126,9 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { }).Build() }, gvk: schema.GroupVersionKind{ - Group: "example.org", + Group: testExampleOrgGroup, Version: "v1", - Kind: "XResource", + Kind: testXResourceKind, }, want: true, // Default to requiring CRD on conversion failure }, @@ -137,6 +141,7 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { typeConverter: tc.setupConverter(), logger: tu.TestLogger(t, false), resourceTypeMap: make(map[schema.GroupVersionKind]bool), + xrdToCRDName: make(map[string]string), } // Call the method under test @@ -159,36 +164,24 @@ func TestSchemaClient_GetCRD(t *testing.T) { } type want struct { - crd *un.Unstructured + crd *extv1.CustomResourceDefinition err error } - // Create a test CRD as unstructured - testCRD := &un.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": map[string]interface{}{ - "name": "xresources.example.org", - }, - "spec": map[string]interface{}{ - "group": "example.org", - "names": map[string]interface{}{ - "kind": "XResource", - "plural": "xresources", - "singular": "xresource", - }, - "scope": "Namespaced", - "versions": []interface{}{ - map[string]interface{}{ - "name": "v1", - "served": true, - "storage": true, - }, - }, - }, - }, - } + // Create a test CRD as typed object + testCRD := tu.NewCRD(testXResourcePlural+"."+testExampleOrgGroup, testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build() + + // Create the same CRD as unstructured for the mock dynamic client using CRD builder + testCRDUnstructuredObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithNamespaceScope(). + Build()) + testCRDUnstructured := &un.Unstructured{Object: testCRDUnstructuredObj} tests := map[string]struct { reason string @@ -203,29 +196,28 @@ func TestSchemaClient_GetCRD(t *testing.T) { dynamicClient := fake.NewSimpleDynamicClient(scheme) dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { getAction := action.(kt.GetAction) - if getAction.GetName() == "xresources.example.org" { - return true, testCRD, nil + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil } return false, nil, nil }) // Create mock type converter that returns "xresources" for the given GVK mockConverter := tu.NewMockTypeConverter(). - WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { - if gvk.Group == "example.org" && gvk.Version == "v1" && gvk.Kind == "XResource" { - return "xresources", nil - } - return "", errors.New("unexpected GVK in test") - }).Build() + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() return dynamicClient, mockConverter }, args: args{ ctx: t.Context(), gvk: schema.GroupVersionKind{ - Group: "example.org", + Group: testExampleOrgGroup, Version: "v1", - Kind: "XResource", + Kind: testXResourceKind, }, }, want: want{ @@ -243,19 +235,18 @@ func TestSchemaClient_GetCRD(t *testing.T) { // Create mock type converter that returns "nonexistentresources" mockConverter := tu.NewMockTypeConverter(). - WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { - if gvk.Group == "example.org" && gvk.Version == "v1" && gvk.Kind == "NonexistentResource" { - return "nonexistentresources", nil - } - return "", errors.New("unexpected GVK in test") - }).Build() + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: "NonexistentResource", + }, "nonexistentresources").Build() return dynamicClient, mockConverter }, args: args{ ctx: t.Context(), gvk: schema.GroupVersionKind{ - Group: "example.org", + Group: testExampleOrgGroup, Version: "v1", Kind: "NonexistentResource", }, @@ -281,9 +272,9 @@ func TestSchemaClient_GetCRD(t *testing.T) { args: args{ ctx: t.Context(), gvk: schema.GroupVersionKind{ - Group: "example.org", + Group: testExampleOrgGroup, Version: "v1", - Kind: "XResource", + Kind: testXResourceKind, }, }, want: want{ @@ -302,6 +293,9 @@ func TestSchemaClient_GetCRD(t *testing.T) { typeConverter: converter, logger: tu.TestLogger(t, false), resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), } crd, err := c.GetCRD(tc.args.ctx, tc.args.gvk) @@ -332,33 +326,623 @@ func TestSchemaClient_GetCRD(t *testing.T) { } } -func TestSchemaClient_ValidateResource(t *testing.T) { +func TestSchemaClient_LoadCRDsFromXRDs(t *testing.T) { ctx := t.Context() + scheme := runtime.NewScheme() + + // Create sample XRD with proper schema using builder + xrd := tu.NewXRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithRawSchema([]byte(`{ + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + } + }, + "status": { + "type": "object" + } + } + }`)). + BuildAsUnstructured() + + // Create corresponding CRD that should exist in cluster using CRD builder + correspondingCRDObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build()) + correspondingCRD := &un.Unstructured{Object: correspondingCRDObj} + + tests := map[string]struct { + reason string + setupClient func() *DefaultSchemaClient + xrds []*un.Unstructured + expectErr bool + errMsg string + expectedCRDs int + }{ + "SuccessfulFetchFromCluster": { + reason: "Should successfully fetch CRDs from cluster for given XRDs", + setupClient: func() *DefaultSchemaClient { + // Setup dynamic client to return the corresponding CRD + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, correspondingCRD, nil + } + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + return &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{xrd}, + expectErr: false, + expectedCRDs: 1, + }, + "EmptyXRDs": { + reason: "Should handle empty XRD list gracefully", + setupClient: func() *DefaultSchemaClient { + return &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{}, + expectErr: false, + expectedCRDs: 0, + }, + "NilXRDs": { + reason: "Should handle nil XRD list gracefully", + setupClient: func() *DefaultSchemaClient { + return &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: nil, + expectErr: false, + expectedCRDs: 0, + }, + "CRDNotFoundInCluster": { + reason: "Should fail fast when required CRD is not found in cluster", + setupClient: func() *DefaultSchemaClient { + // Setup dynamic client to return error for CRD requests + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("CRD not found") + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + return &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{xrd}, + expectErr: true, + errMsg: "cannot fetch required CRD", + expectedCRDs: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client := tc.setupClient() + + err := client.LoadCRDsFromXRDs(ctx, tc.xrds) - testCases := map[string]struct { - resource *un.Unstructured - wantErr bool + if tc.expectErr { + if err == nil { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): unexpected error: %v", tc.reason, err) + return + } + + // Verify expected number of CRDs were cached + crds := client.GetAllCRDs() + if len(crds) != tc.expectedCRDs { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected %d CRDs to be cached, got %d", + tc.reason, tc.expectedCRDs, len(crds)) + } + }) + } +} + +func TestSchemaClient_GetCRDByName(t *testing.T) { + // Create test CRDs + testCRDName := testXResourcePlural + "." + testExampleOrgGroup + testCRD := tu.NewCRD(testCRDName, testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build() + + tests := map[string]struct { + reason string + setupCRDs []*extv1.CustomResourceDefinition + searchName string + expectError bool + expectedCRD *extv1.CustomResourceDefinition + }{ + "CRDFound": { + reason: "Should return CRD when it exists in cache", + setupCRDs: []*extv1.CustomResourceDefinition{testCRD}, + searchName: testCRDName, + expectError: false, + expectedCRD: testCRD, + }, + "CRDNotFound": { + reason: "Should return error when CRD doesn't exist in cache", + setupCRDs: []*extv1.CustomResourceDefinition{}, + searchName: testCRDName, + expectError: true, + expectedCRD: nil, + }, + "DifferentCRDInCache": { + reason: "Should return error when searching for CRD that doesn't exist", + setupCRDs: []*extv1.CustomResourceDefinition{ + tu.NewCRD("other."+testExampleOrgGroup, testExampleOrgGroup, "OtherKind").Build(), + }, + searchName: testCRDName, + expectError: true, + expectedCRD: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Create schema client with test CRDs + client := &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + crds: make([]*extv1.CustomResourceDefinition, 0), + crdByName: make(map[string]*extv1.CustomResourceDefinition), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + xrdToCRDName: make(map[string]string), + } + + // Pre-populate cache with test CRDs + for _, crd := range tc.setupCRDs { + client.addCRD(crd) + } + + // Call GetCRDByName + crd, err := client.GetCRDByName(tc.searchName) + + // Check error expectations + if tc.expectError { + if err == nil { + t.Errorf("\n%s\nGetCRDByName(): expected error but got none", tc.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCRDByName(): unexpected error: %v", tc.reason, err) + return + } + + // Verify returned CRD + if crd == nil { + t.Errorf("\n%s\nGetCRDByName(): expected CRD but got nil", tc.reason) + return + } + + if crd.Name != tc.expectedCRD.Name { + t.Errorf("\n%s\nGetCRDByName(): expected CRD name %s, got %s", + tc.reason, tc.expectedCRD.Name, crd.Name) + } + + if crd.Spec.Group != tc.expectedCRD.Spec.Group { + t.Errorf("\n%s\nGetCRDByName(): expected CRD group %s, got %s", + tc.reason, tc.expectedCRD.Spec.Group, crd.Spec.Group) + } + }) + } +} + +func TestSchemaClient_GetAllCRDs(t *testing.T) { + // Create test CRDs + crd1 := tu.NewCRD("crd1."+testExampleOrgGroup, testExampleOrgGroup, "TestKind1").Build() + crd2 := tu.NewCRD("crd2."+testExampleOrgGroup, testExampleOrgGroup, "TestKind2").Build() + + tests := map[string]struct { + reason string + setupCRDs []*extv1.CustomResourceDefinition + expected int }{ - "SimpleValidResource": { - resource: tu.NewResource("example.org/v1", "XResource", "test-resource"). - WithSpecField("field1", "value1"). - Build(), - wantErr: false, + "NoCRDs": { + reason: "Should return empty slice when no CRDs are cached", + setupCRDs: []*extv1.CustomResourceDefinition{}, + expected: 0, + }, + "MultipleCRDs": { + reason: "Should return all cached CRDs", + setupCRDs: []*extv1.CustomResourceDefinition{crd1, crd2}, + expected: 2, }, - // You could add more tests here if the ValidateResource method had more logic, - // but in the current implementation it's a no-op that always succeeds } - for name, tc := range testCases { + for name, tc := range tests { t.Run(name, func(t *testing.T) { c := &DefaultSchemaClient{ logger: tu.TestLogger(t, false), resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: tc.setupCRDs, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + // Add CRDs to name lookup map + for _, crd := range tc.setupCRDs { + c.crdByName[crd.Name] = crd + } + + crds := c.GetAllCRDs() + + if len(crds) != tc.expected { + t.Errorf("\n%s\nGetAllCRDs(): expected %d CRDs, got %d", tc.reason, tc.expected, len(crds)) + return + } + + // Verify it returns a copy (modifying result shouldn't affect internal state) + if len(crds) > 0 { + originalLen := len(c.crds) + + crds[0] = nil // Modify the returned slice + if len(c.crds) != originalLen || c.crds[0] == nil { + t.Errorf("\n%s\nGetAllCRDs(): should return a copy, not reference to internal slice", tc.reason) + } + } + }) + } +} + +func TestExtractGVKsFromXRD(t *testing.T) { + tests := map[string]struct { + reason string + xrd *un.Unstructured + expectedGVKs []schema.GroupVersionKind + expectErr bool + errMsg string + }{ + "ValidV1XRD": { + reason: "Should extract GVKs from a valid v1 XRD with multiple versions", + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", testXResourcePlural+".example.org"). + WithSpec(map[string]interface{}{ + "group": testExampleOrgGroup, + "names": map[string]interface{}{ + "kind": testXResourceKind, + "plural": testXResourcePlural, + "singular": "xresource", + }, + "versions": []interface{}{ + map[string]interface{}{ + "name": "v1alpha1", + "served": true, + }, + map[string]interface{}{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }, + }).Build(), + expectedGVKs: []schema.GroupVersionKind{ + {Group: testExampleOrgGroup, Version: "v1alpha1", Kind: testXResourceKind}, + {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, + }, + expectErr: false, + }, + "ValidV2XRD": { + reason: "Should extract GVKs from a valid v2 XRD with multiple versions", + xrd: tu.NewResource("apiextensions.crossplane.io/v2", "CompositeResourceDefinition", testXResourcePlural+".example.org"). + WithSpec(map[string]interface{}{ + "group": testExampleOrgGroup, + "names": map[string]interface{}{ + "kind": testXResourceKind, + "plural": testXResourcePlural, + "singular": "xresource", + }, + "versions": []interface{}{ + map[string]interface{}{ + "name": "v1beta1", + "served": true, + }, + map[string]interface{}{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }, + }).Build(), + expectedGVKs: []schema.GroupVersionKind{ + {Group: testExampleOrgGroup, Version: "v1beta1", Kind: testXResourceKind}, + {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, + }, + expectErr: false, + }, + + "UnsupportedAPIVersion": { + reason: "Should fail when XRD has unsupported apiVersion", + // Unsupported version + xrd: tu.NewResource("apiextensions.crossplane.io/v3", "CompositeResourceDefinition", "invalid-xrd"). + WithSpec(map[string]interface{}{ + "group": testExampleOrgGroup, + "names": map[string]interface{}{ + "kind": testXResourceKind, + }, + "versions": []interface{}{ + map[string]interface{}{ + "name": "v1", + "served": true, + }, + }, + }).Build(), + expectErr: true, + errMsg: "unsupported XRD apiVersion", + }, + "ConversionError": { + reason: "Should fail when XRD cannot be converted to typed object", + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "invalid-xrd"). + WithSpec(map[string]interface{}{ + "group": testExampleOrgGroup, + "names": "invalid-names-should-be-object", // Invalid structure + "versions": []interface{}{ + map[string]interface{}{ + "name": "v1", + "served": true, + }, + }, + }).Build(), + expectErr: true, + errMsg: "cannot convert XRD", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + gvks, err := extractGVKsFromXRD(tc.xrd) + + if tc.expectErr { + if err == nil { + t.Errorf("\n%s\nextractGVKsFromXRD(): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nextractGVKsFromXRD(): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return } - err := c.ValidateResource(ctx, tc.resource) - if (err != nil) != tc.wantErr { - t.Errorf("ValidateResource() error = %v, wantErr %v", err, tc.wantErr) + if err != nil { + t.Errorf("\n%s\nextractGVKsFromXRD(): unexpected error: %v", tc.reason, err) + + return + } + + if diff := cmp.Diff(tc.expectedGVKs, gvks); diff != "" { + t.Errorf("\n%s\nextractGVKsFromXRD(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSchemaClient_CachingBehavior(t *testing.T) { + ctx := t.Context() + scheme := runtime.NewScheme() + + // Create test CRD as unstructured for the mock dynamic client using CRD builder + testCRDUnstructuredObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build()) + testCRDUnstructured := &un.Unstructured{Object: testCRDUnstructuredObj} + + tests := map[string]struct { + reason string + setupClient func() (*DefaultSchemaClient, *int) + gvk schema.GroupVersionKind + expectedCalls int + expectCRDCached bool + expectError bool + }{ + "FirstCallFetchesFromCluster": { + reason: "First GetCRD call should fetch from dynamic client", + setupClient: func() (*DefaultSchemaClient, *int) { + callCount := 0 + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + callCount++ + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil + } + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + client := &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + return client, &callCount + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + expectedCalls: 1, + expectCRDCached: true, + expectError: false, + }, + "SecondCallUsesCache": { + reason: "Second GetCRD call should use cache without calling dynamic client", + setupClient: func() (*DefaultSchemaClient, *int) { + callCount := 0 + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + callCount++ + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil + } + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + client := &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + // Pre-populate cache by making first call + gvk := schema.GroupVersionKind{Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind} + _, _ = client.GetCRD(ctx, gvk) + callCount = 0 // Reset counter after pre-population + + return client, &callCount + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + expectedCalls: 0, // Should use cache, no additional calls + expectCRDCached: true, + expectError: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client, callCountPtr := tc.setupClient() + + // Call GetCRD + crd, err := client.GetCRD(ctx, tc.gvk) + + // Check error expectations + if tc.expectError { + if err == nil { + t.Errorf("\n%s\nGetCRD(): expected error but got none", tc.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCRD(): unexpected error: %v", tc.reason, err) + return + } + + // Verify call count + if *callCountPtr != tc.expectedCalls { + t.Errorf("\n%s\nExpected %d calls to dynamic client, got %d", + tc.reason, tc.expectedCalls, *callCountPtr) + } + + // Verify CRD was returned + if crd == nil { + t.Errorf("\n%s\nGetCRD(): expected CRD but got nil", tc.reason) + return + } + + // Verify caching behavior + if tc.expectCRDCached { + allCRDs := client.GetAllCRDs() + if len(allCRDs) == 0 { + t.Errorf("\n%s\nExpected CRD to be cached in GetAllCRDs(), got empty slice", tc.reason) + } + + // Verify consistency between calls + crd2, err2 := client.GetCRD(ctx, tc.gvk) + if err2 != nil { + t.Errorf("\n%s\nSecond GetCRD() call failed: %v", tc.reason, err2) + return + } + + if diff := cmp.Diff(crd, crd2); diff != "" { + t.Errorf("\n%s\nCached CRD differs from original: -want, +got:\n%s", tc.reason, diff) + } } }) } diff --git a/cmd/diff/diff_integration_test.go b/cmd/diff/diff_integration_test.go index a9168ab..cad28a7 100644 --- a/cmd/diff/diff_integration_test.go +++ b/cmd/diff/diff_integration_test.go @@ -968,6 +968,7 @@ Summary: 2 added, 2 modified + compositeDeletePolicy: Background + compositionRef: + name: claim-composition ++ compositionUpdatePolicy: Automatic + coolField: new-value --- @@ -1013,6 +1014,7 @@ Summary: 2 added`, compositeDeletePolicy: Background compositionRef: name: claim-composition + compositionUpdatePolicy: Automatic - coolField: existing-value + coolField: modified-value @@ -1042,6 +1044,69 @@ Summary: 2 modified`, expectedError: false, noColor: true, }, + "XRD defaults should be applied to XR before rendering": { + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedOutput: strings.Join([]string{ + `+++ XTestDefaultResource/test-resource-with-defaults ++ apiVersion: ns.diff.example.org/v1alpha1 ++ kind: XTestDefaultResource ++ metadata: ++ name: test-resource-with-defaults ++ namespace: default ++ spec: ++ region: us-east-1 ++ settings: ++ enabled: true ++ retries: 3 ++ timeout: 30 ++ size: large ++ tags: ++ environment: development + +--- + +Summary: 1 added`, + }, ""), + expectedError: false, + noColor: true, + }, + "XRD defaults should not override user-specified values": { + inputFiles: []string{"testdata/diff/xr-with-overridden-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedOutput: strings.Join([]string{ + `+++ XTestDefaultResource/test-resource-with-overrides ++ apiVersion: ns.diff.example.org/v1alpha1 ++ kind: XTestDefaultResource ++ metadata: ++ name: test-resource-with-overrides ++ namespace: default ++ spec: ++ region: us-west-2 ++ settings: ++ enabled: false ++ retries: 3 ++ timeout: 60 ++ size: xlarge ++ tags: ++ environment: production ++ team: platform + +--- + +Summary: 1 added`, + }, ""), + expectedError: false, + noColor: true, + }, } tu.SetupKubeTestLogger(t) diff --git a/cmd/diff/diff_it_utils_test.go b/cmd/diff/diff_it_utils_test.go index 675183a..b1309d7 100644 --- a/cmd/diff/diff_it_utils_test.go +++ b/cmd/diff/diff_it_utils_test.go @@ -9,8 +9,8 @@ import ( "os" "sort" + tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" gyaml "gopkg.in/yaml.v3" - extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -116,54 +116,34 @@ func createTestCompositionWithExtraResources() (*xpextv1.Composition, error) { // createTestXRD creates a test XRD for the XR. func createTestXRD() *xpextv1.CompositeResourceDefinition { - return &xpextv1.CompositeResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{ - Name: "xexampleresources.example.org", - }, - Spec: xpextv1.CompositeResourceDefinitionSpec{ - Group: "example.org", - Names: extv1.CustomResourceDefinitionNames{ - Kind: "XExampleResource", - Plural: "xexampleresources", - Singular: "xexampleresource", - }, - Versions: []xpextv1.CompositeResourceDefinitionVersion{ - { - Name: "v1", - Served: true, - Referenceable: true, - Schema: &xpextv1.CompositeResourceValidation{ - OpenAPIV3Schema: runtime.RawExtension{ - Raw: []byte(`{ - "type": "object", - "properties": { - "spec": { - "type": "object", - "properties": { - "coolParam": { - "type": "string" - }, - "replicas": { - "type": "integer" - } - } - }, - "status": { - "type": "object", - "properties": { - "coolStatus": { - "type": "string" - } - } - } - } - }`), + return tu.NewXRD("xexampleresources.example.org", "example.org", "XExampleResource"). + WithPlural("xexampleresources"). + WithSingular("xexampleresource"). + WithRawSchema([]byte(`{ + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "coolParam": { + "type": "string" }, - }, + "replicas": { + "type": "integer" + } + } }, - }, - }, - } + "status": { + "type": "object", + "properties": { + "coolStatus": { + "type": "string" + } + } + } + } + }`)). + Build() } // createExtraResource creates a test extra resource. diff --git a/cmd/diff/diff_test.go b/cmd/diff/diff_test.go index 1ed4607..7f695e4 100644 --- a/cmd/diff/diff_test.go +++ b/cmd/diff/diff_test.go @@ -33,6 +33,7 @@ import ( dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor" tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -356,7 +357,7 @@ func TestDiffCommand(t *testing.T) { schemaClient := tu.NewMockSchemaClient(). WithNoResourcesRequiringCRDs(). - WithGetCRD(func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { // For this test, we can return nil as it doesn't focus on validation return nil, errors.New("CRD not found") }). diff --git a/cmd/diff/diffprocessor/diff_processor.go b/cmd/diff/diffprocessor/diff_processor.go index f905963..d282c52 100644 --- a/cmd/diff/diffprocessor/diff_processor.go +++ b/cmd/diff/diffprocessor/diff_processor.go @@ -42,6 +42,7 @@ type DefaultDiffProcessor struct { fnClient xp.FunctionClient compClient xp.CompositionClient defClient xp.DefinitionClient + schemaClient k8.SchemaClient config ProcessorConfig schemaValidator SchemaValidator diffCalculator DiffCalculator @@ -82,6 +83,7 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) fnClient: xpcs.Function, compClient: xpcs.Composition, defClient: xpcs.Definition, + schemaClient: k8cs.Schema, config: config, schemaValidator: schemaValidator, diffCalculator: diffCalculator, @@ -208,6 +210,13 @@ func (p *DefaultDiffProcessor) DiffSingleResource(ctx context.Context, res *un.U return nil, errors.Wrap(err, "cannot get functions from pipeline") } + // Apply XRD defaults before rendering + err = p.applyXRDDefaults(ctx, xr, resourceID) + if err != nil { + p.config.Logger.Debug("Failed to apply XRD defaults", "resource", resourceID, "error", err) + return nil, errors.Wrap(err, "cannot apply XRD defaults") + } + // Perform iterative rendering and requirements reconciliation desired, err := p.RenderWithRequirements(ctx, xr, comp, fns, resourceID) if err != nil { @@ -505,3 +514,60 @@ func (p *DefaultDiffProcessor) propagateNamespacesToManagedResources(_ context.C composedResources[i].SetUnstructuredContent(resource.Object) } } + +// applyXRDDefaults applies default values from the XRD schema to the XR. +func (p *DefaultDiffProcessor) applyXRDDefaults(ctx context.Context, xr *cmp.Unstructured, resourceID string) error { + p.config.Logger.Debug("Applying XRD defaults", "resource", resourceID) + + // Get the XR's GVK + gvk := xr.GroupVersionKind() + + // Find the XRD that defines this XR + var ( + xrd *un.Unstructured + err error + ) + + // Check if this is a claim or an XR + if p.defClient.IsClaimResource(ctx, xr.GetUnstructured()) { + xrd, err = p.defClient.GetXRDForClaim(ctx, gvk) + } else { + xrd, err = p.defClient.GetXRDForXR(ctx, gvk) + } + + if err != nil { + return errors.Wrapf(err, "cannot find XRD for resource %s with GVK %s", resourceID, gvk.String()) + } + + // Get the CRD that corresponds to this XRD using the XRD name + xrdName := xrd.GetName() + + p.config.Logger.Debug("Looking for CRD matching XRD in applyXRDDefaults", "resource", resourceID, "xrdName", xrdName) + + // Use the new GetCRDByName method to directly get the CRD + crdForDefaults, err := p.schemaClient.GetCRDByName(xrdName) + if err != nil { + return errors.Wrapf(err, "cannot find CRD for XRD %s (resource %s)", xrdName, resourceID) + } + + // Apply defaults using the render.DefaultValues function + apiVersion := xr.GetAPIVersion() + xrContent := xr.UnstructuredContent() + + p.config.Logger.Debug("Applying defaults to XR in applyXRDDefaults", + "resource", resourceID, + "apiVersion", apiVersion, + "crdName", crdForDefaults.Name) + + err = render.DefaultValues(xrContent, apiVersion, *crdForDefaults) + if err != nil { + return errors.Wrapf(err, "cannot apply default values for XR %s", resourceID) + } + + // Update the XR with the defaulted content + xr.SetUnstructuredContent(xrContent) + + p.config.Logger.Debug("Successfully applied XRD defaults", "resource", resourceID) + + return nil +} diff --git a/cmd/diff/diffprocessor/diff_processor_test.go b/cmd/diff/diffprocessor/diff_processor_test.go index 8a0afbe..9da3e00 100644 --- a/cmd/diff/diffprocessor/diff_processor_test.go +++ b/cmd/diff/diffprocessor/diff_processor_test.go @@ -15,6 +15,7 @@ import ( tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" gcmp "github.com/google/go-cmp/cmp" "github.com/sergi/go-diff/diffmatchpatch" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -30,6 +31,17 @@ import ( v1 "github.com/crossplane/crossplane/v2/proto/fn/v1" ) +// Test constants to avoid duplication. +const ( + testGroup = "example.org" + testKind = "XR1" + testPlural = "xr1s" + testSingular = "xr1" + testCRDName = testPlural + "." + testGroup + testXRDName = testCRDName + testAPIVersion = "v1" +) + // Ensure MockDiffProcessor implements the DiffProcessor interface. var _ DiffProcessor = &tu.MockDiffProcessor{} @@ -247,6 +259,16 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { Build(), Schema: tu.NewMockSchemaClient(). WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion), nil + } + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1"), nil + } + return nil, errors.New("CRD not found") + }). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). Build(), Type: tu.NewMockTypeConverter().Build(), } @@ -258,6 +280,10 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { Build(), Definition: tu.NewMockDefinitionClient(). WithSuccessfulXRDsFetch([]*un.Unstructured{}). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured()). Build(), Environment: tu.NewMockEnvironmentClient(). WithSuccessfulEnvironmentConfigsFetch([]*un.Unstructured{}). @@ -389,6 +415,16 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { Build(), Schema: tu.NewMockSchemaClient(). WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion), nil + } + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1"), nil + } + return nil, errors.New("CRD not found") + }). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). Build(), Type: tu.NewMockTypeConverter().Build(), } @@ -398,7 +434,12 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { Composition: tu.NewMockCompositionClient(). WithSuccessfulCompositionMatch(composition). Build(), - Definition: tu.NewMockDefinitionClient().Build(), + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured()). + Build(), Environment: tu.NewMockEnvironmentClient(). WithSuccessfulEnvironmentConfigsFetch([]*un.Unstructured{}). Build(), @@ -1082,3 +1123,14 @@ func TestDefaultDiffProcessor_RenderWithRequirements(t *testing.T) { }) } } + +// Helper function to create a test CRD for the given GVK. +func makeTestCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { + return tu.NewCRD(name, group, kind). + WithListKind(kind+"List"). + WithPlural(strings.ToLower(kind)+"s"). + WithSingular(strings.ToLower(kind)). + WithVersion(version, true, true). + WithStandardSchema("coolField"). + Build() +} diff --git a/cmd/diff/diffprocessor/schema_validator.go b/cmd/diff/diffprocessor/schema_validator.go index 094e041..100490e 100644 --- a/cmd/diff/diffprocessor/schema_validator.go +++ b/cmd/diff/diffprocessor/schema_validator.go @@ -8,7 +8,6 @@ import ( k8 "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -16,7 +15,6 @@ import ( cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" "github.com/crossplane/crossplane/v2/cmd/crank/beta/validate" - "github.com/crossplane/crossplane/v2/cmd/crank/common/crd" "github.com/crossplane/crossplane/v2/cmd/crank/common/loggerwriter" ) @@ -38,7 +36,6 @@ type DefaultSchemaValidator struct { defClient xp.DefinitionClient schemaClient k8.SchemaClient logger logging.Logger - crds []*extv1.CustomResourceDefinition } // NewSchemaValidator creates a new DefaultSchemaValidator. @@ -47,7 +44,6 @@ func NewSchemaValidator(sClient k8.SchemaClient, dClient xp.DefinitionClient, lo defClient: dClient, schemaClient: sClient, logger: logger, - crds: []*extv1.CustomResourceDefinition{}, } } @@ -62,28 +58,21 @@ func (v *DefaultSchemaValidator) LoadCRDs(ctx context.Context) error { return errors.Wrap(err, "cannot get XRDs") } - // Convert XRDs to CRDs - crds, err := crd.ConvertToCRDs(xrds) + // Use SchemaClient to load CRDs from XRDs + err = v.schemaClient.LoadCRDsFromXRDs(ctx, xrds) if err != nil { - v.logger.Debug("Failed to convert XRDs to CRDs", "error", err) - return errors.Wrap(err, "cannot convert XRDs to CRDs") + v.logger.Debug("Failed to load CRDs into schema client", "error", err) + return errors.Wrap(err, "cannot load CRDs into schema client") } - v.crds = crds - v.logger.Debug("Loaded CRDs", "count", len(crds)) + // Logging is handled internally by the schema client return nil } -// SetCRDs sets the CRDs directly, useful for testing or when CRDs are pre-loaded. -func (v *DefaultSchemaValidator) SetCRDs(crds []*extv1.CustomResourceDefinition) { - v.crds = crds - v.logger.Debug("Set CRDs directly", "count", len(crds)) -} - // GetCRDs returns the current CRDs. func (v *DefaultSchemaValidator) GetCRDs() []*extv1.CustomResourceDefinition { - return v.crds + return v.schemaClient.GetAllCRDs() } // ValidateResources validates resources using schema validation. @@ -105,7 +94,7 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U // Ensure we have all the required CRDs v.logger.Debug("Ensuring required CRDs for validation", - "cachedCRDs", len(v.crds), + "cachedCRDs", len(v.schemaClient.GetAllCRDs()), "resourceCount", len(resources)) err := v.EnsureComposedResourceCRDs(ctx, resources) @@ -120,7 +109,7 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U // Use skipSuccessLogs=true to avoid cluttering the output with success messages v.logger.Debug("Performing schema validation", "resourceCount", len(resources)) - err = validate.SchemaValidation(ctx, resources, v.crds, true, true, loggerWriter) + err = validate.SchemaValidation(ctx, resources, v.schemaClient.GetAllCRDs(), true, true, loggerWriter) if err != nil { return errors.Wrap(err, "schema validation failed") } @@ -146,39 +135,19 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U // EnsureComposedResourceCRDs checks if we have all the CRDs needed for the cpd resources // and fetches any missing ones from the cluster. func (v *DefaultSchemaValidator) EnsureComposedResourceCRDs(ctx context.Context, resources []*un.Unstructured) error { - // Create a map of existing CRDs by GVK for quick lookup - existingCRDs := make(map[schema.GroupVersionKind]bool) - - for _, crd := range v.crds { - for _, version := range crd.Spec.Versions { - gvk := schema.GroupVersionKind{ - Group: crd.Spec.Group, - Version: version.Name, - Kind: crd.Spec.Names.Kind, - } - existingCRDs[gvk] = true - } - } + v.logger.Debug("Ensuring required CRDs for validation", "resourceCount", len(resources)) - // Collect GVKs from resources that aren't already covered - missingGVKs := make(map[schema.GroupVersionKind]bool) + // Collect unique GVKs from resources + uniqueGVKs := make(map[schema.GroupVersionKind]bool) for _, res := range resources { gvk := res.GroupVersionKind() - if !existingCRDs[gvk] { - missingGVKs[gvk] = true - } + uniqueGVKs[gvk] = true } - // If we have all the CRDs already, we're done - if len(missingGVKs) == 0 { - v.logger.Debug("All required CRDs are already cached") - return nil - } + // Try to fetch each required CRD - GetCRD will use cache if already present + var missingCRDs []string - v.logger.Debug("Fetching additional CRDs", "missingCount", len(missingGVKs)) - - // Fetch missing CRDs - for gvk := range missingGVKs { + for gvk := range uniqueGVKs { // Skip resources that don't require CRDs if !v.schemaClient.IsCRDRequired(ctx, gvk) { v.logger.Debug("Skipping built-in resource type, no CRD required", @@ -187,32 +156,22 @@ func (v *DefaultSchemaValidator) EnsureComposedResourceCRDs(ctx context.Context, continue } - // Try to get the CRD using the client's GetCRD method - crdObj, err := v.schemaClient.GetCRD(ctx, gvk) + // Try to get the CRD using the client's GetCRD method (which will cache it) + _, err := v.schemaClient.GetCRD(ctx, gvk) if err != nil { - v.logger.Debug("CRD not found (continuing)", + v.logger.Debug("CRD not found", "gvk", gvk.String(), "error", err) - - return errors.New("unable to find CRD for " + gvk.String()) - } - - // Convert to CRD - crd := &extv1.CustomResourceDefinition{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crd); err != nil { - v.logger.Debug("Error converting CRD (continuing)", - "gvk", gvk.String(), - "error", err) - - continue + missingCRDs = append(missingCRDs, gvk.String()) } + } - // Add to our cache - v.crds = append(v.crds, crd) - v.logger.Debug("Added CRD to cache", "crdName", crd.Name) + // If any CRDs are missing, fail + if len(missingCRDs) > 0 { + return errors.Errorf("unable to find CRDs for: %v", missingCRDs) } - v.logger.Debug("Finished ensuring CRDs", "totalCRDs", len(v.crds)) + v.logger.Debug("Finished ensuring CRDs") return nil } @@ -221,40 +180,16 @@ func (v *DefaultSchemaValidator) EnsureComposedResourceCRDs(ctx context.Context, func (v *DefaultSchemaValidator) getResourceScope(ctx context.Context, gvk schema.GroupVersionKind) (string, error) { v.logger.Debug("Getting resource scope", "gvk", gvk.String()) - // First check if we have the CRD in our cache - for _, crd := range v.crds { - for _, version := range crd.Spec.Versions { - if crd.Spec.Group == gvk.Group && - version.Name == gvk.Version && - crd.Spec.Names.Kind == gvk.Kind { - scope := string(crd.Spec.Scope) - v.logger.Debug("Found scope in cached CRDs", "gvk", gvk.String(), "scope", scope) - - return scope, nil - } - } - } - - // If not in cache, try to fetch the CRD - crdObj, err := v.schemaClient.GetCRD(ctx, gvk) + // Get the typed CRD directly + crd, err := v.schemaClient.GetCRD(ctx, gvk) if err != nil { v.logger.Debug("Failed to get CRD for scope lookup", "gvk", gvk.String(), "error", err) return "", errors.Wrapf(err, "cannot get CRD for %s to determine scope", gvk.String()) } - // Convert to CRD and extract scope - crd := &extv1.CustomResourceDefinition{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crd); err != nil { - v.logger.Debug("Error converting CRD for scope lookup", "gvk", gvk.String(), "error", err) - return "", errors.Wrapf(err, "cannot convert CRD for %s", gvk.String()) - } - scope := string(crd.Spec.Scope) v.logger.Debug("Retrieved scope from CRD", "gvk", gvk.String(), "scope", scope) - // Add to our cache for future use - v.crds = append(v.crds, crd) - return scope, nil } diff --git a/cmd/diff/diffprocessor/schema_validator_test.go b/cmd/diff/diffprocessor/schema_validator_test.go index bcf2ddc..2fc0b4d 100644 --- a/cmd/diff/diffprocessor/schema_validator_test.go +++ b/cmd/diff/diffprocessor/schema_validator_test.go @@ -8,7 +8,6 @@ import ( xp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/crossplane" tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -19,23 +18,29 @@ import ( var _ SchemaValidator = (*tu.MockSchemaValidator)(nil) +const ( + testExampleOrg = "example.org" + testComposedResource = "ComposedResource" + testCpdOrg = "cpd.org" +) + func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { ctx := t.Context() // Create a sample XR and cpd resources for validation - xr := tu.NewResource("example.org/v1", "XR", "test-xr"). + xr := tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr"). InNamespace("default"). WithSpecField("field", "value"). Build() - composedResource1 := tu.NewResource("cpd.org/v1", "ComposedResource", "resource1"). + composedResource1 := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource1"). InNamespace("default"). WithCompositeOwner("test-xr"). WithCompositionResourceName("resource1"). WithSpecField("field", "value"). BuildUComposed() - composedResource2 := tu.NewResource("cpd.org/v1", "ComposedResource", "resource2"). + composedResource2 := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource2"). InNamespace("default"). WithCompositeOwner("test-xr"). WithCompositionResourceName("resource2"). @@ -43,8 +48,8 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { BuildUComposed() // Create sample CRDs for validation - xrCRD := makeCRD("xrs.example.org", "XR", "example.org", "v1") - composedCRD := makeCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1") + xrCRD := makeCRD("xrs."+testExampleOrg, "XR", testExampleOrg, "v1") + composedCRD := makeCRD("testComposedResources."+testCpdOrg, "testComposedResource", testCpdOrg, "v1") tests := map[string]struct { setupClients func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) @@ -56,41 +61,33 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { }{ "SuccessfulValidationWithPreloadedCRDs": { setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { - return tu.NewMockSchemaClient().Build(), tu.NewMockDefinitionClient().Build() + sch := tu.NewMockSchemaClient(). + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: testExampleOrg, Kind: "XR"}: xrCRD, + {Group: testCpdOrg, Kind: "testComposedResource"}: composedCRD, + }). + WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + + return sch, tu.NewMockDefinitionClient().Build() }, xr: xr, composed: []cpd.Unstructured{*composedResource1, *composedResource2}, - preloadedCRDs: []*extv1.CustomResourceDefinition{xrCRD, composedCRD}, + preloadedCRDs: []*extv1.CustomResourceDefinition{}, // No longer needed expectedErr: false, }, "SuccessfulValidationWithFetchedCRDs": { setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { - // Convert CRDs to unstructured for the mock client - xrCRDUn := &un.Unstructured{} - _ = runtime.DefaultUnstructuredConverter.FromUnstructured( - MustToUnstructured(xrCRD), - xrCRDUn, - ) - - composedCRDUn := &un.Unstructured{} - _ = runtime.DefaultUnstructuredConverter.FromUnstructured( - MustToUnstructured(composedCRD), - composedCRDUn, - ) - sch := tu.NewMockSchemaClient(). - // Add GetCRD implementation - WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - if gvk.Group == "example.org" && gvk.Kind == "XR" { - return xrCRDUn, nil - } - if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { - return composedCRDUn, nil - } - return nil, errors.New("CRD not found") + // Add GetCRD implementation for typed CRDs + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: testExampleOrg, Kind: "XR"}: xrCRD, + {Group: testCpdOrg, Kind: "testComposedResource"}: composedCRD, }). // Implement IsCRDRequired to return true for our test resources WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). Build() def := tu.NewMockDefinitionClient(). WithSuccessfulXRDsFetch([]*un.Unstructured{}). @@ -104,26 +101,14 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { }, "MissingCRD": { setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { - // Only provide the XR CRD, not the cpd resource CRD - xrCRDUn := &un.Unstructured{} - _ = runtime.DefaultUnstructuredConverter.FromUnstructured( - MustToUnstructured(xrCRD), - xrCRDUn, - ) - sch := tu.NewMockSchemaClient(). - // Add GetCRD implementation - WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - if gvk.Group == "example.org" && gvk.Kind == "XR" { - return xrCRDUn, nil - } - // Return not found for cpd resource CRD - return nil, errors.New("CRD not found") - }). + // Add GetCRD implementation for typed CRDs + WithFoundCRD(testExampleOrg, "XR", xrCRD). // Add this line to make only Composed resources require CRDs: WithResourcesRequiringCRDs( - schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, ). + WithCachingBehavior(). Build() def := tu.NewMockDefinitionClient(). WithSuccessfulXRDsFetch([]*un.Unstructured{}). @@ -135,7 +120,7 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { preloadedCRDs: []*extv1.CustomResourceDefinition{}, // Now we expect an error because we've configured it to require a CRD but can't find it expectedErr: true, - expectedErrMsg: "unable to find CRD for cpd.org/v1, Kind=ComposedResource", + expectedErrMsg: "unable to find CRDs for", }, "ValidationError": { setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { @@ -147,13 +132,13 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { ) sch := tu.NewMockSchemaClient(). - // Add GetCRD implementation - WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - if gvk.Group == "example.org" && gvk.Kind == "XR" { - return nil, errors.New("CRD not found") // Force validation to use preloaded CRDs + // Add GetCRD implementation for typed CRDs + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testExampleOrg && gvk.Kind == "XR" { + return createCRDWithStringField(xrCRD), nil } - if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { - return composedCRDUn, nil + if gvk.Group == testCpdOrg && gvk.Kind == "testComposedResource" { + return composedCRD, nil } return nil, errors.New("CRD not found") }). @@ -164,7 +149,7 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { def := tu.NewMockDefinitionClient().Build() return sch, def }, - xr: tu.NewResource("example.org/v1", "XR", "test-xr"). + xr: tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr"). InNamespace("default"). WithSpecField("field", int64(123)). Build(), @@ -183,10 +168,7 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { // Create the schema validator validator := NewSchemaValidator(schemaClient, defClient, logger) - // Set any preloaded CRDs - if len(tt.preloadedCRDs) > 0 { - validator.(*DefaultSchemaValidator).SetCRDs(tt.preloadedCRDs) - } + // CRDs are now provided via mock SchemaClient // Call the function under test err := validator.ValidateResources(ctx, tt.xr, tt.composed) @@ -217,12 +199,12 @@ func TestDefaultSchemaValidator_EnsureComposedResourceCRDs(t *testing.T) { ctx := t.Context() // Create sample resources - xr := tu.NewResource("example.org/v1", "XR", "test-xr").InNamespace("default").Build() - cmpd := tu.NewResource("cpd.org/v1", "ComposedResource", "resource1").InNamespace("default").Build() + xr := tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr").InNamespace("default").Build() + cmpd := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource1").InNamespace("default").Build() // Create sample CRDs - xrCRD := makeCRD("xrs.example.org", "XR", "example.org", "v1") - composedCRD := makeCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1") + xrCRD := makeCRD("xrs."+testExampleOrg, "XR", testExampleOrg, "v1") + composedCRD := makeCRD("testComposedResources."+testCpdOrg, "testComposedResource", testCpdOrg, "v1") tests := map[string]struct { setupClient func() *tu.MockSchemaClient @@ -232,48 +214,44 @@ func TestDefaultSchemaValidator_EnsureComposedResourceCRDs(t *testing.T) { }{ "AllCRDsAlreadyCached": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() }, initialCRDs: []*extv1.CustomResourceDefinition{xrCRD, composedCRD}, resources: []*un.Unstructured{xr, cmpd}, - expectedCRDLen: 2, // No change, all CRDs already cached + expectedCRDLen: 0, // No CRDs should be cached since no resources require CRDs }, "FetchMissingCRDs": { setupClient: func() *tu.MockSchemaClient { - // Convert the cpd CRD to un for the mock - composedCRDUn := &un.Unstructured{} - _ = runtime.DefaultUnstructuredConverter.FromUnstructured( - MustToUnstructured(composedCRD), - composedCRDUn, - ) - return tu.NewMockSchemaClient(). - // Use the new GetCRD method instead of GetResource - WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { - return composedCRDUn, nil - } - return nil, errors.New("CRD not found") - }). + // Use the new GetCRD method with typed CRDs + WithFoundCRD(testCpdOrg, "testComposedResource", composedCRD). // Make sure cpd resources require CRDs WithResourcesRequiringCRDs( - schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, ). + WithCachingBehavior(). Build() }, initialCRDs: []*extv1.CustomResourceDefinition{xrCRD}, // Only XR CRD is cached resources: []*un.Unstructured{xr, cmpd}, - expectedCRDLen: 2, // Should fetch the missing cpd CRD + expectedCRDLen: 1, // Should fetch the missing cpd CRD (only cpd resource requires CRD) }, "SomeCRDsMissing": { setupClient: func() *tu.MockSchemaClient { return tu.NewMockSchemaClient(). WithCRDNotFound(). + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, + ). + WithCachingBehavior(). Build() }, initialCRDs: []*extv1.CustomResourceDefinition{xrCRD}, // Only XR CRD is cached resources: []*un.Unstructured{xr, cmpd}, - expectedCRDLen: 1, // Still only has the initial XR CRD + expectedCRDLen: 0, // No CRDs should be fetched successfully since GetCRD returns not found }, } @@ -282,9 +260,8 @@ func TestDefaultSchemaValidator_EnsureComposedResourceCRDs(t *testing.T) { schemaClient := tt.setupClient() logger := tu.TestLogger(t, false) - // Create the schema validator with initial CRDs + // Create the schema validator - CRDs provided via mock SchemaClient validator := NewSchemaValidator(schemaClient, tu.NewMockDefinitionClient().Build(), logger) - validator.(*DefaultSchemaValidator).SetCRDs(tt.initialCRDs) // Call the function under test _ = validator.(*DefaultSchemaValidator).EnsureComposedResourceCRDs(ctx, tt.resources) @@ -304,7 +281,7 @@ func TestDefaultSchemaValidator_LoadCRDs(t *testing.T) { // Create sample CRDs as un xrdUn := tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xrd1"). - WithSpecField("group", "example.org"). + WithSpecField("group", "testExampleOrg"). WithSpecField("names", map[string]interface{}{ "kind": "XR", "plural": "xrs", @@ -358,8 +335,8 @@ func TestDefaultSchemaValidator_LoadCRDs(t *testing.T) { defClient := tt.setupClient() logger := tu.TestLogger(t, false) - // Create the schema validator - validator := NewSchemaValidator(tu.NewMockSchemaClient().Build(), defClient, logger) + // Create the schema validator with caching behavior + validator := NewSchemaValidator(tu.NewMockSchemaClient().WithCachingBehavior().Build(), defClient, logger) // Call the function under test err := validator.(*DefaultSchemaValidator).LoadCRDs(ctx) @@ -389,43 +366,13 @@ func TestDefaultSchemaValidator_LoadCRDs(t *testing.T) { // Helper function to create a simple CRD. func makeCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { - return &extv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Spec: extv1.CustomResourceDefinitionSpec{ - Group: group, - Names: extv1.CustomResourceDefinitionNames{ - Kind: kind, - ListKind: kind + "List", - Plural: strings.ToLower(kind) + "s", - Singular: strings.ToLower(kind), - }, - Scope: extv1.NamespaceScoped, - Versions: []extv1.CustomResourceDefinitionVersion{ - { - Name: version, - Served: true, - Storage: true, - Schema: &extv1.CustomResourceValidation{ - OpenAPIV3Schema: &extv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]extv1.JSONSchemaProps{ - "spec": { - Type: "object", - Properties: map[string]extv1.JSONSchemaProps{ - "field": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - }, - } + return tu.NewCRD(name, group, kind). + WithListKind(kind+"List"). + WithPlural(strings.ToLower(kind)+"s"). + WithSingular(strings.ToLower(kind)). + WithVersion(version, true, true). + WithStringFieldSchema("field"). + Build() } // Create a CRD with a string field validation. @@ -466,10 +413,10 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { ctx := t.Context() // Create CRDs with different scopes - namespacedCRD := makeCRD("namespacedresources.example.org", "NamespacedResource", "example.org", "v1") + namespacedCRD := makeCRD("namespacedresources."+testExampleOrg, "NamespacedResource", testExampleOrg, "v1") namespacedCRD.Spec.Scope = extv1.NamespaceScoped - clusterCRD := makeCRD("clusterresources.example.org", "ClusterResource", "example.org", "v1") + clusterCRD := makeCRD("clusterresources."+testExampleOrg, "ClusterResource", testExampleOrg, "v1") clusterCRD.Spec.Scope = extv1.ClusterScoped tests := map[string]struct { @@ -483,10 +430,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }{ "NamespacedResourceValidNamespace": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() }, - preloadedCRDs: []*extv1.CustomResourceDefinition{namespacedCRD}, - resource: tu.NewResource("example.org/v1", "NamespacedResource", "test-resource"). + preloadedCRDs: []*extv1.CustomResourceDefinition{}, // No longer needed + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). InNamespace("default"). Build(), expectedNamespace: "default", @@ -495,10 +444,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "NamespacedResourceMissingNamespace": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{namespacedCRD}, - resource: tu.NewResource("example.org/v1", "NamespacedResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). Build(), // No namespace expectedNamespace: "default", isClaimRoot: false, @@ -507,10 +458,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "NamespacedResourceWrongNamespace": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{namespacedCRD}, - resource: tu.NewResource("example.org/v1", "NamespacedResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). InNamespace("wrong"). Build(), expectedNamespace: "default", @@ -520,10 +473,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "ClusterResourceValidNoNamespace": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{clusterCRD}, - resource: tu.NewResource("example.org/v1", "ClusterResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). Build(), // No namespace - correct for cluster-scoped expectedNamespace: "", isClaimRoot: false, @@ -531,10 +486,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "ClusterResourceInvalidNamespace": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{clusterCRD}, - resource: tu.NewResource("example.org/v1", "ClusterResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). InNamespace("default"). Build(), expectedNamespace: "", @@ -544,10 +501,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "ClusterResourceFromNamespacedXR": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{clusterCRD}, - resource: tu.NewResource("example.org/v1", "ClusterResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). Build(), expectedNamespace: "default", // XR is namespaced isClaimRoot: false, @@ -556,10 +515,12 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { }, "ClusterResourceFromNamespacedClaim": { setupClient: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient().Build() + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{clusterCRD}, - resource: tu.NewResource("example.org/v1", "ClusterResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). Build(), expectedNamespace: "default", // Claim is namespaced isClaimRoot: true, // But it's a claim, so allowed @@ -568,13 +529,13 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { "CRDNotFound": { setupClient: func() *tu.MockSchemaClient { return tu.NewMockSchemaClient(). - WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { return nil, errors.New("CRD not found") }). Build() }, preloadedCRDs: []*extv1.CustomResourceDefinition{}, - resource: tu.NewResource("example.org/v1", "UnknownResource", "test-resource"). + resource: tu.NewResource(testExampleOrg+"/v1", "UnknownResource", "test-resource"). Build(), expectedNamespace: "default", isClaimRoot: false, @@ -588,9 +549,8 @@ func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { schemaClient := tt.setupClient() logger := tu.TestLogger(t, false) - // Create the schema validator + // Create the schema validator - CRDs provided via mock SchemaClient validator := NewSchemaValidator(schemaClient, tu.NewMockDefinitionClient().Build(), logger) - validator.(*DefaultSchemaValidator).SetCRDs(tt.preloadedCRDs) // Call the function under test err := validator.ValidateScopeConstraints(ctx, tt.resource, tt.expectedNamespace, tt.isClaimRoot) diff --git a/cmd/diff/testdata/diff/crds/nopclaim-crd.yaml b/cmd/diff/testdata/diff/crds/nopclaim-crd.yaml index 0ba0ce8..2b0dec7 100644 --- a/cmd/diff/testdata/diff/crds/nopclaim-crd.yaml +++ b/cmd/diff/testdata/diff/crds/nopclaim-crd.yaml @@ -30,6 +30,16 @@ spec: type: string compositeDeletePolicy: type: string + enum: + - Background + - Foreground + default: "Background" + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + default: "Automatic" compositionSelector: type: object properties: @@ -37,6 +47,29 @@ spec: type: object additionalProperties: type: string + publishConnectionDetailsTo: + type: object + properties: + name: + type: string + configRef: + type: object + properties: + name: + type: string + metadata: + type: object + properties: + labels: + type: object + additionalProperties: + type: string + annotations: + type: object + additionalProperties: + type: string + writeConnectionSecretsToNamespace: + type: string status: type: object properties: diff --git a/cmd/diff/testdata/diff/crds/xdefaultresource-ns-crd.yaml b/cmd/diff/testdata/diff/crds/xdefaultresource-ns-crd.yaml new file mode 100644 index 0000000..bdc0edd --- /dev/null +++ b/cmd/diff/testdata/diff/crds/xdefaultresource-ns-crd.yaml @@ -0,0 +1,79 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xtestdefaultresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XTestDefaultResource + plural: xtestdefaultresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + default: "us-east-1" + description: "AWS region with default value" + size: + type: string + default: "small" + description: "Instance size with default" + settings: + type: object + default: + enabled: true + timeout: 30 + properties: + enabled: + type: boolean + default: true + timeout: + type: integer + default: 30 + retries: + type: integer + default: 3 + tags: + type: object + default: + environment: "development" + properties: + environment: + type: string + default: "development" + team: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string \ No newline at end of file diff --git a/cmd/diff/testdata/diff/resources/claim-xrd.yaml b/cmd/diff/testdata/diff/resources/claim-xrd.yaml index 08ac1e8..1f7af9f 100644 --- a/cmd/diff/testdata/diff/resources/claim-xrd.yaml +++ b/cmd/diff/testdata/diff/resources/claim-xrd.yaml @@ -1,7 +1,7 @@ apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - name: xnopresources.diff.example.org + name: xnopresources2.diff.example.org spec: group: diff.example.org names: @@ -35,3 +35,38 @@ spec: type: object additionalProperties: type: string + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + default: "Automatic" + compositeDeletePolicy: + type: string + enum: + - Background + - Foreground + default: "Background" + publishConnectionDetailsTo: + type: object + properties: + name: + type: string + configRef: + type: object + properties: + name: + type: string + metadata: + type: object + properties: + labels: + type: object + additionalProperties: + type: string + annotations: + type: object + additionalProperties: + type: string + writeConnectionSecretsToNamespace: + type: string diff --git a/cmd/diff/testdata/diff/resources/composition-with-defaults.yaml b/cmd/diff/testdata/diff/resources/composition-with-defaults.yaml new file mode 100644 index 0000000..0eedc80 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/composition-with-defaults.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xdefaultresources.ns.diff.example.org + labels: + provider: nop + service: nop +spec: + mode: Pipeline + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XTestDefaultResource + pipeline: + - step: auto-ready + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/diff/testdata/diff/resources/xrd-with-defaults.yaml b/cmd/diff/testdata/diff/resources/xrd-with-defaults.yaml new file mode 100644 index 0000000..bbd6eb8 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/xrd-with-defaults.yaml @@ -0,0 +1,79 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xtestdefaultresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XTestDefaultResource + plural: xtestdefaultresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + default: "us-east-1" + description: "AWS region with default value" + size: + type: string + default: "small" + description: "Instance size with default" + settings: + type: object + default: + enabled: true + timeout: 30 + properties: + enabled: + type: boolean + default: true + timeout: + type: integer + default: 30 + retries: + type: integer + default: 3 + tags: + type: object + default: + environment: "development" + properties: + environment: + type: string + default: "development" + team: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string \ No newline at end of file diff --git a/cmd/diff/testdata/diff/xr-with-missing-defaults.yaml b/cmd/diff/testdata/diff/xr-with-missing-defaults.yaml new file mode 100644 index 0000000..d1d9007 --- /dev/null +++ b/cmd/diff/testdata/diff/xr-with-missing-defaults.yaml @@ -0,0 +1,9 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default +spec: + # Only specify one field, leaving others to use defaults from XRD + size: "large" + # Note: region, settings, and tags are not specified and should get defaults \ No newline at end of file diff --git a/cmd/diff/testdata/diff/xr-with-overridden-defaults.yaml b/cmd/diff/testdata/diff/xr-with-overridden-defaults.yaml new file mode 100644 index 0000000..53f5dfc --- /dev/null +++ b/cmd/diff/testdata/diff/xr-with-overridden-defaults.yaml @@ -0,0 +1,19 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-overrides + namespace: default +spec: + # Override the default region (default: us-east-1) + region: "us-west-2" + # Override the default size (default: small) + size: "xlarge" + # Partially override settings (default: {enabled: true, timeout: 30}) + settings: + enabled: false # Override default true + timeout: 60 # Override default 30 + # retries should get default value of 3 + # Override default tags (default: {environment: "development"}) + tags: + environment: "production" # Override default "development" + team: "platform" # New field, no default to override \ No newline at end of file diff --git a/cmd/diff/testutils/mock_builder.go b/cmd/diff/testutils/mock_builder.go index 7f593ae..0904643 100644 --- a/cmd/diff/testutils/mock_builder.go +++ b/cmd/diff/testutils/mock_builder.go @@ -6,6 +6,7 @@ import ( "io" "strings" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -222,26 +223,76 @@ func (b *MockSchemaClientBuilder) WithInitialize(fn func(context.Context) error) } // WithGetCRD sets the GetCRD behavior. -func (b *MockSchemaClientBuilder) WithGetCRD(fn func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error)) *MockSchemaClientBuilder { +func (b *MockSchemaClientBuilder) WithGetCRD(fn func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error)) *MockSchemaClientBuilder { b.mock.GetCRDFn = fn return b } // WithCRDNotFound sets GetCRD to return a not found error. func (b *MockSchemaClientBuilder) WithCRDNotFound() *MockSchemaClientBuilder { - return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { return nil, errors.New("CRD not found") }) } // WithSuccessfulCRDFetch sets GetCRD to return a specific CRD. -func (b *MockSchemaClientBuilder) WithSuccessfulCRDFetch(crd *un.Unstructured) *MockSchemaClientBuilder { - return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { - if crd.GetKind() != "CustomResourceDefinition" { - return nil, errors.Errorf("setup error: desired return from GetCRD isn't a CRD but a %s", crd.GetKind()) +func (b *MockSchemaClientBuilder) WithSuccessfulCRDFetch(crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return crd, nil + }) +} + +// WithFoundCRD sets GetCRD to return a specific CRD when the group and kind match. +func (b *MockSchemaClientBuilder) WithFoundCRD(group, kind string, crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + // If we don't have an existing GetCRD function, create a new one + if b.mock.GetCRDFn == nil { + return b.WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == group && gvk.Kind == kind { + return crd, nil + } + + return nil, errors.New("CRD not found") + }) + } + + // If we already have a GetCRD function, wrap it to add this mapping + originalFn := b.mock.GetCRDFn + + return b.WithGetCRD(func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == group && gvk.Kind == kind { + return crd, nil } - return crd, nil + return originalFn(ctx, gvk) + }) +} + +// WithFoundCRDs sets GetCRD to return specific CRDs when the group and kind match, with a fallback error. +func (b *MockSchemaClientBuilder) WithFoundCRDs(crdMappings map[schema.GroupKind]*extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + groupKind := schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind} + if crd, found := crdMappings[groupKind]; found { + return crd, nil + } + + return nil, errors.New("CRD not found") + }) +} + +// WithGetCRDByName sets the GetCRDByName behavior. +func (b *MockSchemaClientBuilder) WithGetCRDByName(fn func(string) (*extv1.CustomResourceDefinition, error)) *MockSchemaClientBuilder { + b.mock.GetCRDByNameFn = fn + return b +} + +// WithSuccessfulCRDByNameFetch sets GetCRDByName to return a specific CRD for a given name. +func (b *MockSchemaClientBuilder) WithSuccessfulCRDByNameFetch(name string, crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRDByName(func(searchName string) (*extv1.CustomResourceDefinition, error) { + if searchName == name { + return crd, nil + } + + return nil, errors.Errorf("CRD with name %s not found", searchName) }) } @@ -284,6 +335,113 @@ func (b *MockSchemaClientBuilder) WithValidateResource(fn func(context.Context, return b } +// WithGetAllCRDs sets the GetAllCRDs behavior. +func (b *MockSchemaClientBuilder) WithGetAllCRDs(fn func() []*extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + b.mock.GetAllCRDsFn = fn + return b +} + +// WithCachingBehavior creates a mock that simulates caching CRDs when GetCRD or LoadCRDsFromXRDs is called. +func (b *MockSchemaClientBuilder) WithCachingBehavior() *MockSchemaClientBuilder { + cachedCRDs := make(map[string]*extv1.CustomResourceDefinition) + + // Override GetCRD to track cached CRDs + originalGetCRD := b.mock.GetCRDFn + b.mock.GetCRDFn = func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + // Check cache first + key := gvk.String() + if crd, ok := cachedCRDs[key]; ok { + return crd, nil + } + + // Call original function + if originalGetCRD != nil { + crd, err := originalGetCRD(ctx, gvk) + if err == nil && crd != nil { + // Cache the CRD + cachedCRDs[key] = crd + } + + return crd, err + } + + return nil, errors.New("GetCRD not implemented") + } + + // Override LoadCRDsFromXRDs to simulate caching CRDs converted from XRDs + originalLoadCRDs := b.mock.LoadCRDsFromXRDsFn + b.mock.LoadCRDsFromXRDsFn = func(ctx context.Context, xrds []*un.Unstructured) error { + // Call original function first + if originalLoadCRDs != nil { + err := originalLoadCRDs(ctx, xrds) + if err != nil { + return err + } + } + + // Simulate caching CRDs converted from XRDs + // For test purposes, create a simple CRD from each XRD + for _, xrd := range xrds { + group, _, _ := un.NestedString(xrd.Object, "spec", "group") + if group == "" { + continue // Skip invalid XRDs + } + + names, ok, _ := un.NestedMap(xrd.Object, "spec", "names") + if !ok { + continue + } + + kind, _ := names["kind"].(string) + if kind == "" { + continue + } + + // Create a simple CRD for this XRD + crd := &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: xrd.GetName(), + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + } + + gvk := schema.GroupVersionKind{Group: group, Version: "v1", Kind: kind} + cachedCRDs[gvk.String()] = crd + } + + return nil + } + + // Override GetAllCRDs to return cached CRDs + b.mock.GetAllCRDsFn = func() []*extv1.CustomResourceDefinition { + var result []*extv1.CustomResourceDefinition + for _, crd := range cachedCRDs { + result = append(result, crd) + } + + return result + } + + return b +} + // Build returns the built mock. func (b *MockSchemaClientBuilder) Build() *MockSchemaClient { return b.mock @@ -382,6 +540,42 @@ func (b *MockTypeConverterBuilder) WithDefaultGetResourceNameForGVK() *MockTypeC }) } +// WithResourceNameForGVK sets GetResourceNameForGVK to return a specific resource name for a given GVK. +func (b *MockTypeConverterBuilder) WithResourceNameForGVK(gvk schema.GroupVersionKind, resourceName string) *MockTypeConverterBuilder { + // If we don't have an existing GetResourceNameForGVK function, create a new one + if b.mock.GetResourceNameForGVKFn == nil { + return b.WithGetResourceNameForGVK(func(_ context.Context, testGVK schema.GroupVersionKind) (string, error) { + if testGVK.Group == gvk.Group && testGVK.Version == gvk.Version && testGVK.Kind == gvk.Kind { + return resourceName, nil + } + + return "", errors.New("unexpected GVK in test") + }) + } + + // If we already have a GetResourceNameForGVK function, wrap it to add this mapping + originalFn := b.mock.GetResourceNameForGVKFn + + return b.WithGetResourceNameForGVK(func(ctx context.Context, testGVK schema.GroupVersionKind) (string, error) { + if testGVK.Group == gvk.Group && testGVK.Version == gvk.Version && testGVK.Kind == gvk.Kind { + return resourceName, nil + } + + return originalFn(ctx, testGVK) + }) +} + +// WithResourceNameForGVKs sets GetResourceNameForGVK to return specific resource names for given GVKs, with a fallback error. +func (b *MockTypeConverterBuilder) WithResourceNameForGVKs(gvkMappings map[schema.GroupVersionKind]string) *MockTypeConverterBuilder { + return b.WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { + if resourceName, found := gvkMappings[gvk]; found { + return resourceName, nil + } + + return "", errors.New("unexpected GVK in test") + }) +} + // Build returns the built mock. func (b *MockTypeConverterBuilder) Build() *MockTypeConverter { return b.mock @@ -1070,6 +1264,279 @@ func (b *ResourceBuilder) BuildUComposed() *cpd.Unstructured { // endregion +// region CRD builders + +// ====================================================================================== +// CRD Building Helpers +// ====================================================================================== + +// CRDBuilder helps construct CRD resources for testing. +type CRDBuilder struct { + crd *extv1.CustomResourceDefinition +} + +// NewCRD creates a new CRDBuilder. +func NewCRD(name, group, kind string) *CRDBuilder { + return &CRDBuilder{ + crd: &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CustomResourceDefinition", + APIVersion: "apiextensions.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + }, + } +} + +// WithPlural sets the plural name for the CRD. +func (b *CRDBuilder) WithPlural(plural string) *CRDBuilder { + b.crd.Spec.Names.Plural = plural + return b +} + +// WithSingular sets the singular name for the CRD. +func (b *CRDBuilder) WithSingular(singular string) *CRDBuilder { + b.crd.Spec.Names.Singular = singular + return b +} + +// WithScope sets the scope (Namespaced or Cluster) for the CRD. +func (b *CRDBuilder) WithScope(scope extv1.ResourceScope) *CRDBuilder { + b.crd.Spec.Scope = scope + return b +} + +// WithClusterScope sets the CRD to be cluster-scoped. +func (b *CRDBuilder) WithClusterScope() *CRDBuilder { + return b.WithScope(extv1.ClusterScoped) +} + +// WithNamespaceScope sets the CRD to be namespace-scoped. +func (b *CRDBuilder) WithNamespaceScope() *CRDBuilder { + return b.WithScope(extv1.NamespaceScoped) +} + +// WithVersion adds a version to the CRD. +func (b *CRDBuilder) WithVersion(name string, served, storage bool) *CRDBuilder { + version := extv1.CustomResourceDefinitionVersion{ + Name: name, + Served: served, + Storage: storage, + } + b.crd.Spec.Versions = append(b.crd.Spec.Versions, version) + + return b +} + +// WithSchema adds an OpenAPI v3 schema to the first version. +func (b *CRDBuilder) WithSchema(schema *extv1.JSONSchemaProps) *CRDBuilder { + if len(b.crd.Spec.Versions) > 0 { + b.crd.Spec.Versions[0].Schema = &extv1.CustomResourceValidation{ + OpenAPIV3Schema: schema, + } + } + + return b +} + +// WithStringFieldSchema adds a simple string field schema to the CRD. +func (b *CRDBuilder) WithStringFieldSchema(fieldName string) *CRDBuilder { + schema := &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + fieldName: { + Type: "string", + }, + }, + }, + "status": { + Type: "object", + }, + }, + } + + return b.WithSchema(schema) +} + +// WithStandardSchema adds a standard schema with common Kubernetes fields and a spec field. +func (b *CRDBuilder) WithStandardSchema(specFieldName string) *CRDBuilder { + schema := &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": {Type: "string"}, + "kind": {Type: "string"}, + "metadata": {Type: "object"}, + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + specFieldName: {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + } + + return b.WithSchema(schema) +} + +// WithListKind sets the ListKind name for the CRD. +func (b *CRDBuilder) WithListKind(listKind string) *CRDBuilder { + b.crd.Spec.Names.ListKind = listKind + return b +} + +// Build returns the built CRD. +func (b *CRDBuilder) Build() *extv1.CustomResourceDefinition { + return b.crd.DeepCopy() +} + +// endregion + +// region XRD builders + +// ====================================================================================== +// XRD Building Helpers +// ====================================================================================== + +// XRDBuilder helps construct XRD resources for testing. +type XRDBuilder struct { + xrd *xpextv1.CompositeResourceDefinition +} + +// NewXRD creates a new XRDBuilder. +func NewXRD(name, group, kind string) *XRDBuilder { + return &XRDBuilder{ + xrd: &xpextv1.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CompositeResourceDefinition", + APIVersion: "apiextensions.crossplane.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: xpextv1.CompositeResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Versions: []xpextv1.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Referenceable: true, + }, + }, + }, + }, + } +} + +// WithPlural sets the plural name for the XRD. +func (b *XRDBuilder) WithPlural(plural string) *XRDBuilder { + b.xrd.Spec.Names.Plural = plural + return b +} + +// WithSingular sets the singular name for the XRD. +func (b *XRDBuilder) WithSingular(singular string) *XRDBuilder { + b.xrd.Spec.Names.Singular = singular + return b +} + +// WithClaimNames sets the claim names for the XRD. +func (b *XRDBuilder) WithClaimNames(kind, plural string) *XRDBuilder { + b.xrd.Spec.ClaimNames = &extv1.CustomResourceDefinitionNames{ + Kind: kind, + Plural: plural, + } + + return b +} + +// WithVersion adds a version to the XRD. +func (b *XRDBuilder) WithVersion(name string, served, referenceable bool) *XRDBuilder { + version := xpextv1.CompositeResourceDefinitionVersion{ + Name: name, + Served: served, + Referenceable: referenceable, + } + b.xrd.Spec.Versions = append(b.xrd.Spec.Versions, version) + + return b +} + +// WithSchema adds an OpenAPI v3 schema to the first version. +func (b *XRDBuilder) WithSchema(schema *extv1.JSONSchemaProps) *XRDBuilder { + if len(b.xrd.Spec.Versions) > 0 { + // Convert JSONSchemaProps to RawExtension + rawBytes, err := json.Marshal(schema) + if err != nil { + // In tests, this should not happen, but if it does, we'll just skip the schema + return b + } + + b.xrd.Spec.Versions[0].Schema = &xpextv1.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{ + Raw: rawBytes, + }, + } + } + + return b +} + +// WithRawSchema adds a raw JSON schema to the first version. +func (b *XRDBuilder) WithRawSchema(rawJSON []byte) *XRDBuilder { + if len(b.xrd.Spec.Versions) > 0 { + b.xrd.Spec.Versions[0].Schema = &xpextv1.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{ + Raw: rawJSON, + }, + } + } + + return b +} + +// Build returns the built XRD. +func (b *XRDBuilder) Build() *xpextv1.CompositeResourceDefinition { + return b.xrd.DeepCopy() +} + +// BuildAsUnstructured returns the built XRD as an unstructured object. +func (b *XRDBuilder) BuildAsUnstructured() *un.Unstructured { + xrd := b.Build() + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(xrd) + if err != nil { + // This should not happen in tests, but if it does, we'll return an empty unstructured + return &un.Unstructured{} + } + + return &un.Unstructured{Object: obj} +} + +// endregion + // region Composition builders // ====================================================================================== diff --git a/cmd/diff/testutils/mocks.go b/cmd/diff/testutils/mocks.go index 86f8161..4869c88 100644 --- a/cmd/diff/testutils/mocks.go +++ b/cmd/diff/testutils/mocks.go @@ -5,6 +5,7 @@ import ( "io" dt "github.com/crossplane-contrib/crossplane-diff/cmd/diff/renderer/types" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -393,9 +394,12 @@ func (m *MockResourceClient) IsNamespacedResource(ctx context.Context, gvk schem // MockSchemaClient implements the kubernetes.SchemaClient interface. type MockSchemaClient struct { InitializeFn func(ctx context.Context) error - GetCRDFn func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + GetCRDFn func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) + GetCRDByNameFn func(name string) (*extv1.CustomResourceDefinition, error) IsCRDRequiredFn func(ctx context.Context, gvk schema.GroupVersionKind) bool ValidateResourceFn func(ctx context.Context, resource *un.Unstructured) error + LoadCRDsFromXRDsFn func(ctx context.Context, xrds []*un.Unstructured) error + GetAllCRDsFn func() []*extv1.CustomResourceDefinition } // Initialize implements kubernetes.SchemaClient. @@ -408,7 +412,7 @@ func (m *MockSchemaClient) Initialize(ctx context.Context) error { } // GetCRD implements kubernetes.SchemaClient. -func (m *MockSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { +func (m *MockSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { if m.GetCRDFn != nil { return m.GetCRDFn(ctx, gvk) } @@ -416,6 +420,15 @@ func (m *MockSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKi return nil, errors.New("GetCRD not implemented") } +// GetCRDByName implements kubernetes.SchemaClient. +func (m *MockSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { + if m.GetCRDByNameFn != nil { + return m.GetCRDByNameFn(name) + } + + return nil, errors.New("GetCRDByName not implemented") +} + // IsCRDRequired implements kubernetes.SchemaClient. func (m *MockSchemaClient) IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool { if m.IsCRDRequiredFn != nil { @@ -434,6 +447,24 @@ func (m *MockSchemaClient) ValidateResource(ctx context.Context, resource *un.Un return nil } +// LoadCRDsFromXRDs implements kubernetes.SchemaClient. +func (m *MockSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { + if m.LoadCRDsFromXRDsFn != nil { + return m.LoadCRDsFromXRDsFn(ctx, xrds) + } + + return nil +} + +// GetAllCRDs implements kubernetes.SchemaClient. +func (m *MockSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { + if m.GetAllCRDsFn != nil { + return m.GetAllCRDsFn() + } + + return []*extv1.CustomResourceDefinition{} +} + // MockApplyClient implements the kubernetes.ApplyClient interface. type MockApplyClient struct { InitializeFn func(ctx context.Context) error