Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions cmd/crossplane/simulate/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2026 The Crossplane Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package simulate

import (
"context"

extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"

xrpkg "github.com/crossplane/cli/v2/pkg/xr"
)

// crdDefaults caches the cluster's CRDs by group and kind, so the approximate
// fallback can apply the API server's schema defaults to a desired manifest
// before diffing it against the live resource. The live resource was defaulted
// by the API server on admission, and applying the manifest would default it
// again, so fields a manifest omits but the CRD defaults (deletionPolicy,
// managementPolicies, ...) are not removals and must not plan as ones. Like
// gather, this is best effort: a kind without a fetchable CRD simply diffs
// undefaulted, and the miss is cached so a run over many resources of that
// kind probes the cluster once.
type crdDefaults struct {
cache map[schema.GroupKind]*extv1.CustomResourceDefinition
}

func newCRDDefaults() *crdDefaults {
return &crdDefaults{cache: map[schema.GroupKind]*extv1.CustomResourceDefinition{}}
}

// apply returns a copy of desired with its CRD's defaults applied, as the API
// server would admit it, or desired itself when the cluster does not serve the
// kind or its CRD yields no usable schema.
func (c *crdDefaults) apply(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, desired *unstructured.Unstructured) *unstructured.Unstructured {
gvk := desired.GroupVersionKind()
crd, ok := c.cache[gvk.GroupKind()]
if !ok {
crd = fetchCRD(ctx, kubeClient, rmapper, gvk)
c.cache[gvk.GroupKind()] = crd
}
if crd == nil {
return desired
}

defaulted := desired.DeepCopy()
if err := xrpkg.ApplyCRDDefaults(defaulted.Object, desired.GetAPIVersion(), *crd); err != nil {
return desired
}
return defaulted
}

// fetchCRD fetches the CRD serving gvk. nil means no defaulting is possible:
// the kind is not served or its CRD is unreadable.
func fetchCRD(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, gvk schema.GroupVersionKind) *extv1.CustomResourceDefinition {
mapping, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil
}

u := &unstructured.Unstructured{}
u.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})
if err := kubeClient.Get(ctx, client.ObjectKey{Name: mapping.Resource.Resource + "." + gvk.Group}, u); err != nil {
return nil
}
crd := &extv1.CustomResourceDefinition{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, crd); err != nil {
return nil
}
return crd
}
13 changes: 9 additions & 4 deletions cmd/crossplane/simulate/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,9 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s

plans := make([]*plan.ResourcePlan, 0, len(resources))
stores := newObjectStoreCache()
crds := newCRDDefaults()
for _, desired := range resources {
plans = append(plans, c.simulateOne(ctx, kubeClient, rmapper, defaultNamespace, mgr, stores, desired))
plans = append(plans, c.simulateOne(ctx, kubeClient, rmapper, defaultNamespace, mgr, stores, crds, desired))
}

return renderer.Render(k.Stdout, plans)
Expand Down Expand Up @@ -325,7 +326,7 @@ type router interface {
// to a diff server, and fall back to the approximate client-side diff when no
// server can plan it. Failures become per-resource errors so one resource
// never fails the whole run.
func (c *Cmd) simulateOne(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, mgr router, stores *objectStoreCache, desired *unstructured.Unstructured) *plan.ResourcePlan {
func (c *Cmd) simulateOne(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, mgr router, stores *objectStoreCache, crds *crdDefaults, desired *unstructured.Unstructured) *plan.ResourcePlan {
if desired.GetName() == "" {
p := plan.NewResourcePlan(desired)
p.Error = "resource has no name"
Expand All @@ -347,7 +348,11 @@ func (c *Cmd) simulateOne(ctx context.Context, kubeClient client.Client, rmapper
gk := desired.GroupVersionKind().GroupKind()
planClient, ok := mgr.Route(gk)
if !ok {
return plan.Fallback(desired, live, fmt.Sprintf("no diff server for %s", gk))
// The fallback diffs desired against live verbatim, but live was
// defaulted by the API server on admission and applying the manifest
// would default it again. Apply the CRD's defaults to desired first,
// so defaulted fields the manifest omits do not plan as removals.
return plan.Fallback(crds.apply(ctx, kubeClient, rmapper, desired), live, fmt.Sprintf("no diff server for %s", gk))
}

pctx, cancel := context.WithTimeout(ctx, c.Timeout)
Expand All @@ -358,7 +363,7 @@ func (c *Cmd) simulateOne(ctx context.Context, kubeClient client.Client, rmapper
p, err := plan.Plan(pctx, planClient, desired, live, store)
if err != nil {
if plan.IsNotSupported(err) {
return plan.Fallback(desired, live, "the provider cannot compute a diff for this resource")
return plan.Fallback(crds.apply(ctx, kubeClient, rmapper, desired), live, "the provider cannot compute a diff for this resource")
}
p := plan.NewResourcePlan(desired)
p.Error = fmt.Sprintf("diff server cannot plan: %s", err)
Expand Down
93 changes: 86 additions & 7 deletions cmd/crossplane/simulate/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func TestSimulateOne(t *testing.T) {
}},
}}

got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), vpc("my-vpc", "10.1.0.0/16"))
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16"))

if got.Action != plan.ActionUpdate || got.Approximate || got.Error != "" {
t.Errorf("simulateOne(...): want a provider-computed update, got %+v", got)
Expand All @@ -205,7 +205,7 @@ func TestSimulateOne(t *testing.T) {
kube := fake.NewClientBuilder().WithScheme(s).Build()
pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}}

got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), vpc("new-vpc", "10.0.0.0/16"))
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("new-vpc", "10.0.0.0/16"))

if got.Action != plan.ActionCreate || got.Error != "" {
t.Errorf("simulateOne(...): want a create, got %+v", got)
Expand All @@ -219,7 +219,7 @@ func TestSimulateOne(t *testing.T) {
live := vpc("my-vpc", "10.0.0.0/16")
kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build()

got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), vpc("my-vpc", "10.1.0.0/16"))
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16"))

if !got.Approximate || got.Action != plan.ActionUpdate {
t.Errorf("simulateOne(...): want an approximate update fallback, got %+v", got)
Expand All @@ -231,18 +231,97 @@ func TestSimulateOne(t *testing.T) {
kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build()
pc := &fakePlanClient{err: notSupportedErr(t)}

got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), vpc("my-vpc", "10.1.0.0/16"))
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16"))

if !got.Approximate || got.Action != plan.ActionUpdate {
t.Errorf("simulateOne(...): want an approximate fallback on DIFF_COMPUTATION_NOT_SUPPORTED, got %+v", got)
}
})

t.Run("FallbackAppliesCRDDefaults", func(t *testing.T) {
// The live resource carries fields the API server defaulted on
// admission (deletionPolicy, managementPolicies) that the manifest
// omits. Applying the manifest would default them right back, so the
// fallback must not plan them as removals.
crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}

defScheme := runtime.NewScheme()
defScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{})
defScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{})
defScheme.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{})
defScheme.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{})

crd := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": map[string]any{"name": "vpcs.ec2.aws.upbound.io"},
"spec": map[string]any{
"group": "ec2.aws.upbound.io",
"names": map[string]any{"kind": "VPC", "listKind": "VPCList", "plural": "vpcs", "singular": "vpc"},
"scope": "Cluster",
"versions": []any{map[string]any{
"name": "v1beta1",
"served": true, "storage": true,
"schema": map[string]any{"openAPIV3Schema": map[string]any{
"type": "object",
"properties": map[string]any{
"spec": map[string]any{
"type": "object",
"properties": map[string]any{
"deletionPolicy": map[string]any{"type": "string", "default": "Delete"},
"managementPolicies": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []any{"*"}},
"forProvider": map[string]any{"type": "object", "properties": map[string]any{"cidrBlock": map[string]any{"type": "string"}}},
},
},
},
}},
}},
},
}}

live := vpc("my-vpc", "10.0.0.0/16")
_ = unstructured.SetNestedField(live.Object, "Delete", "spec", "deletionPolicy")
_ = unstructured.SetNestedStringSlice(live.Object, []string{"*"}, "spec", "managementPolicies")

crdGets := 0
kube := fake.NewClientBuilder().WithScheme(defScheme).WithObjects(live, crd).
WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if obj.GetObjectKind().GroupVersionKind().Kind == "CustomResourceDefinition" {
crdGets++
}
return c.Get(ctx, key, obj, opts...)
}}).Build()

crds := newCRDDefaults()
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), crds, vpc("my-vpc", "10.1.0.0/16"))

if !got.Approximate || got.Action != plan.ActionUpdate {
t.Fatalf("simulateOne(...): want an approximate update fallback, got %+v", got)
}
want := []plan.FieldChange{{
Path: "spec.forProvider.cidrBlock",
Actual: &plan.Value{Value: "10.0.0.0/16"},
Planned: &plan.Value{Value: "10.1.0.0/16"},
}}
if diff := cmp.Diff(want, got.Changes); diff != "" {
t.Errorf("simulateOne(...): want only the real change, no removals of defaulted fields, -want, +got:\n%s", diff)
}

// An identical manifest plans as a no-op, and the CRD is served from
// the cache rather than fetched again.
if again := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), crds, vpc("my-vpc", "10.0.0.0/16")); again.Action != plan.ActionNoOp {
t.Errorf("simulateOne(...): want a no-op for a manifest omitting only defaulted fields, got %+v", again)
}
if crdGets != 1 {
t.Errorf("simulateOne(...): want 1 CRD fetch for 2 resources of the same kind, got %d", crdGets)
}
})

t.Run("ServerErrorBecomesResourceError", func(t *testing.T) {
kube := fake.NewClientBuilder().WithScheme(s).Build()
pc := &fakePlanClient{err: status.Error(codes.Internal, "boom")}

got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), vpc("my-vpc", "10.1.0.0/16"))
got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16"))

if got.Error == "" {
t.Errorf("simulateOne(...): want a per-resource error, got %+v", got)
Expand Down Expand Up @@ -283,7 +362,7 @@ func TestSimulateOne(t *testing.T) {
kube := fake.NewClientBuilder().WithScheme(seedScheme).WithObjects(pc, secret).Build()
pc2 := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}}

got := cmd.simulateOne(ctx, kube, seedRM, "default", &fakeRouter{client: pc2}, newObjectStoreCache(), vpc("my-vpc", "10.0.0.0/16"))
got := cmd.simulateOne(ctx, kube, seedRM, "default", &fakeRouter{client: pc2}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.0.0.0/16"))

if got.Error != "" {
t.Fatalf("simulateOne(...): unexpected error: %s", got.Error)
Expand Down Expand Up @@ -411,7 +490,7 @@ func TestSimulateOne(t *testing.T) {
emptyRM := meta.NewDefaultRESTMapper(nil)
pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}}

got := cmd.simulateOne(ctx, kube, emptyRM, "default", &fakeRouter{client: pc}, newObjectStoreCache(), vpc("my-vpc", "10.0.0.0/16"))
got := cmd.simulateOne(ctx, kube, emptyRM, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.0.0.0/16"))

if got.Action != plan.ActionCreate || got.Error != "" {
t.Errorf("simulateOne(...): want a create for an unserved kind, got %+v", got)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ require (
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5
sigs.k8s.io/controller-runtime v0.23.3
sigs.k8s.io/kind v0.30.0
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482
sigs.k8s.io/yaml v1.6.0
)

Expand Down Expand Up @@ -325,5 +326,4 @@ require (
sigs.k8s.io/kustomize/api v0.20.1 // indirect
sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
)
Loading
Loading