diff --git a/cmd/crossplane/resource/resource.go b/cmd/crossplane/resource/resource.go index c930152e..5186ad61 100644 --- a/cmd/crossplane/resource/resource.go +++ b/cmd/crossplane/resource/resource.go @@ -18,12 +18,14 @@ limitations under the License. package resource import ( + "github.com/crossplane/cli/v2/cmd/crossplane/simulate" "github.com/crossplane/cli/v2/cmd/crossplane/trace" "github.com/crossplane/cli/v2/cmd/crossplane/validate" ) // Cmd contains commands for working with Crossplane resources. type Cmd struct { + Simulate simulate.Cmd `cmd:"" help:"Preview what applying managed resources would do." maturity:"alpha"` Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource for troubleshooting."` Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` } diff --git a/cmd/crossplane/simulate/help/simulate.md b/cmd/crossplane/simulate/help/simulate.md new file mode 100644 index 00000000..0f9f328b --- /dev/null +++ b/cmd/crossplane/simulate/help/simulate.md @@ -0,0 +1,48 @@ +This command previews what applying one or more managed resources would do, +without applying anything: which resources it would create, which fields it +would change, and, most importantly, which changes the provider could only +apply by destroying and recreating the external resource. + +For each input resource the CLI fetches the live managed resource from the +cluster, if one exists, and asks the provider itself for the verdict: it runs +the provider's package image locally as a diff server (a stateless, +credential-free gRPC service upjet-based providers serve) and sends it the +desired and live state. The provider computes its own diff, so the preview +includes replacement detection, values known only after apply, and redacted +sensitive values. A resource with no live counterpart plans as a create. + +Which providers run as diff servers comes from the current kubeconfig context +by default: the CLI lists the providers installed on the cluster and uses +their package images, so the preview matches the provider versions the +cluster actually runs. --provider-images overrides that discovery, for +example to preview how a provider version that is not installed yet would +treat existing resources. + +Not every provider serves diffs. A provider package declares the capability +in its metadata, and a resource whose provider serves no diff server (or +whose diff server cannot compute a diff for it) still appears in the preview +with an approximate client-side comparison, marked [?], rather than failing +the run or silently dropping out. + +Nothing simulate does writes to the cluster or the cloud: it reads the +cluster with your kubeconfig and RBAC, and the diff servers run locally with +no credentials and no cluster access. + +Diff server startup takes a few seconds per provider image. In an +edit-simulate-edit loop, --keep-diff-servers leaves the containers running so +the next run reuses them; they are named `crossplane-diff-server-DIGEST` and +are cleaned up by any later run without the flag, or manually with docker rm. + +Examples: + + # Preview what applying resources.yaml would do. + crossplane resource simulate -f resources.yaml + + # Preview against a specific provider version instead of the installed one. + crossplane resource simulate -f resources.yaml --provider-images xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0 + + # Keep diff servers running for faster subsequent runs. + crossplane resource simulate -f resources.yaml --keep-diff-servers + + # Emit machine-readable results for CI gating. + crossplane resource simulate -f resources.yaml -o json diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go new file mode 100644 index 00000000..69ecbb26 --- /dev/null +++ b/cmd/crossplane/simulate/simulate.go @@ -0,0 +1,350 @@ +/* +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 contains the resource simulate command: preview what +// applying managed resources would do, to the cluster and to the cloud +// behind it, by asking the providers' own diff servers. +package simulate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/alecthomas/kong" + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "github.com/spf13/afero" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/crossplane/apis/v2/pkg" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/load" + "github.com/crossplane/cli/v2/internal/diffserver" + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/kube" + "github.com/crossplane/cli/v2/internal/plan" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" + + _ "embed" +) + +//go:embed help/simulate.md +var helpDetail string + +// Cmd previews what applying managed resources would do. +type Cmd struct { + Filename string `help:"YAML file(s) or directory with managed resources, or '-' for stdin (comma-separated)." predictor:"yaml_file_or_directory" required:"" short:"f"` + + ProviderImages []string `help:"Override the provider images to run as diff servers. Defaults to the providers installed on the cluster." name:"provider-images"` + KeepDiffServers bool `help:"Leave diff server containers running for reuse by later runs." name:"keep-diff-servers"` + Context string `default:"" help:"Kubernetes context." name:"context" predictor:"context" short:"c"` + CacheDir string `default:"~/.crossplane/cache" help:"Cache directory for downloaded provider packages." name:"cache-dir" predictor:"directory"` + Timeout time.Duration `default:"2m" help:"Per-resource plan timeout."` + Output string `default:"plan" enum:"plan,json,yaml" help:"Output format. One of: plan, json, yaml." name:"output" short:"o"` + + Impersonation kube.ImpersonationFlags `embed:""` +} + +// Help returns the help message for the simulate command. +func (c *Cmd) Help() string { + return helpDetail +} + +// AfterApply expands the cache directory. +func (c *Cmd) AfterApply() error { + if strings.HasPrefix(c.CacheDir, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return errors.Wrap(err, "cannot determine home directory for cache dir") + } + c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:]) + } + return nil +} + +// Run runs the simulate command. +func (c *Cmd) Run(k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + loader, err := load.NewLoader(c.Filename) + if err != nil { + return errors.Wrapf(err, "cannot load resources from %q", c.Filename) + } + resources, err := loader.Load() + if err != nil { + return errors.Wrapf(err, "cannot load resources from %q", c.Filename) + } + if len(resources) == 0 { + return errors.Errorf("no resources found in %q", c.Filename) + } + + if err := docker.Check(ctx); err != nil { + return errors.Wrap(err, "cannot connect to Docker, which simulate needs to run provider diff servers") + } + + kubeClient, rmapper, defaultNamespace, err := c.setupKubeClient(logger) + if err != nil { + return err + } + + images := c.ProviderImages + if len(images) == 0 { + if images, err = installedProviderImages(ctx, kubeClient); err != nil { + return errors.Wrap(err, "cannot discover providers installed on the cluster") + } + } + if len(images) == 0 { + _, _ = fmt.Fprintln(k.Stderr, "No providers found to run as diff servers; all results will be approximate.") + } + + pkgClient, err := clixpkg.NewClient(clixpkg.NewRemoteFetcher(), clixpkg.WithCacheDir(afero.NewOsFs(), c.CacheDir)) + if err != nil { + return errors.Wrap(err, "cannot create package client") + } + + mgr := diffserver.NewManager(pkgClient, logger, diffserver.WithKeepServers(c.KeepDiffServers)) + var skipped []diffserver.Skipped + if err := sp.WrapWithSuccessSpinner("Starting provider diff servers", func() error { + var err error + skipped, err = mgr.Start(ctx, images) + return err + }); err != nil { + return errors.Wrap(err, "cannot start diff servers") + } + defer func() { + if err := mgr.Stop(context.Background()); err != nil { + _, _ = fmt.Fprintf(k.Stderr, "Warning: cannot clean up diff servers: %s\n", err) + } + }() + for _, s := range skipped { + _, _ = fmt.Fprintf(k.Stderr, "Not running a diff server for %s: %s\n", s.Image, s.Reason) + } + + plans := make([]*plan.ResourcePlan, 0, len(resources)) + for _, desired := range resources { + plans = append(plans, c.simulateOne(ctx, kubeClient, rmapper, defaultNamespace, mgr, desired)) + } + + return plan.Render(k.Stdout, c.Output, plans) +} + +// router routes a resource's group and kind to a diff server client. It is +// the slice of diffserver.Manager the per-resource flow needs, extracted so +// tests can fake routing. +type router interface { + Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) +} + +// simulateOne plans a single resource: fetch its live counterpart, route it +// 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, desired *unstructured.Unstructured) *plan.ResourcePlan { + if desired.GetName() == "" { + p := plan.NewResourcePlan(desired) + p.Error = "resource has no name" + return p + } + + live, err := fetchLive(ctx, kubeClient, rmapper, defaultNamespace, desired) + if err != nil { + p := plan.NewResourcePlan(desired) + p.Error = fmt.Sprintf("cannot fetch live resource: %s", err) + return p + } + + gk := desired.GroupVersionKind().GroupKind() + planClient, ok := mgr.Route(gk) + if !ok { + return plan.Fallback(desired, live, fmt.Sprintf("no diff server for %s", gk)) + } + + pctx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + + store := objectStore(ctx, kubeClient, rmapper, defaultNamespace, desired) + + 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") + } + p := plan.NewResourcePlan(desired) + p.Error = fmt.Sprintf("diff server cannot plan: %s", err) + return p + } + return p +} + +// fetchLive returns the live counterpart of the desired resource, or nil when +// it does not exist (including when its kind is not installed on the +// cluster), in which case the resource plans as a create. +func fetchLive(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, desired *unstructured.Unstructured) (*unstructured.Unstructured, error) { + gvk := desired.GroupVersionKind() + + mapping, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + // The kind is not served by the cluster: nothing can exist yet. The + // diff server can still plan the resource as a create. + return nil, nil //nolint:nilerr // Intentional: an unserved kind means no live counterpart, not a failure. + } + + key := client.ObjectKey{Name: desired.GetName()} + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + key.Namespace = desired.GetNamespace() + if key.Namespace == "" { + key.Namespace = defaultNamespace + } + } + + live := &unstructured.Unstructured{} + live.SetGroupVersionKind(gvk) + if err := kubeClient.Get(ctx, key, live); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return live, nil +} + +// objectStore gathers the cluster objects the diff server needs to resolve +// the desired resource: today the ProviderConfig it references. The diff +// server is offline and only sees what travels in the request, so anything it +// must resolve is fetched here, with the user's own kubeconfig and RBAC. The +// ProviderConfig's credentials Secret deliberately stays out: the server runs +// offline with placeholder credentials and never needs the real ones. +// Gathering is best effort: a missing object simply stays out of the store, +// and the server reports precisely what it could not resolve. +func objectStore(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, desired *unstructured.Unstructured) []*unstructured.Unstructured { + name, _, _ := unstructured.NestedString(desired.Object, "spec", "providerConfigRef", "name") + if name == "" { + name = "default" + } + kind, _, _ := unstructured.NestedString(desired.Object, "spec", "providerConfigRef", "kind") + if kind == "" { + kind = "ProviderConfig" + } + + // The provider config lives in the provider family's group, one label + // above the resource's group: ec2.aws.m.upbound.io -> aws.m.upbound.io. + group := desired.GroupVersionKind().Group + i := strings.Index(group, ".") + if i < 0 { + return nil + } + group = group[i+1:] + + mapping, err := rmapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}) + if err != nil { + return nil + } + + pc := &unstructured.Unstructured{} + pc.SetGroupVersionKind(mapping.GroupVersionKind) + key := client.ObjectKey{Name: name} + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + key.Namespace = desired.GetNamespace() + if key.Namespace == "" { + key.Namespace = defaultNamespace + } + } + if err := kubeClient.Get(ctx, key, pc); err != nil { + return nil + } + return []*unstructured.Unstructured{pc} +} + +// installedProviderImages lists the package images of the providers installed +// on the cluster, preferring the resolved identifier over the possibly +// constraint-form spec.package. +func installedProviderImages(ctx context.Context, kubeClient client.Client) ([]string, error) { + providers := &pkgv1.ProviderList{} + if err := kubeClient.List(ctx, providers); err != nil { + return nil, err + } + images := make([]string, 0, len(providers.Items)) + for _, p := range providers.Items { + img := p.Status.CurrentIdentifier + if img == "" { + img = p.Spec.Package + } + if img != "" { + images = append(images, img) + } + } + return images, nil +} + +func (c *Cmd) setupKubeClient(logger logging.Logger) (client.Client, meta.RESTMapper, string, error) { + clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{CurrentContext: c.Context}, + ) + + kubeconfig, err := clientconfig.ClientConfig() + if err != nil { + return nil, nil, "", errors.Wrap(err, "failed to get kubeconfig") + } + + c.Impersonation.Apply(kubeconfig) + + if kubeconfig.QPS == 0 { + kubeconfig.QPS = 20 + } + if kubeconfig.Burst == 0 { + kubeconfig.Burst = 30 + } + + logger.Debug("Found kubeconfig") + + cl, err := client.New(kubeconfig, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return nil, nil, "", errors.Wrap(err, "cannot init kubeclient") + } + _ = pkg.AddToScheme(cl.Scheme()) + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeconfig) + if err != nil { + return nil, nil, "", errors.Wrap(err, "cannot get discovery client") + } + d := memory.NewMemCacheClient(discoveryClient) + rmapper := restmapper.NewShortcutExpander(restmapper.NewDeferredDiscoveryRESTMapper(d), d, nil) + + namespace, _, err := clientconfig.Namespace() + if err != nil { + return nil, nil, "", errors.Wrap(err, "failed to get namespace from kubeconfig") + } + + return cl, rmapper, namespace, nil +} diff --git a/cmd/crossplane/simulate/simulate_test.go b/cmd/crossplane/simulate/simulate_test.go new file mode 100644 index 00000000..bac37614 --- /dev/null +++ b/cmd/crossplane/simulate/simulate_test.go @@ -0,0 +1,218 @@ +/* +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" + "testing" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "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/fake" + + "github.com/crossplane/cli/v2/internal/plan" +) + +var vpcGVK = schema.GroupVersionKind{Group: "ec2.aws.upbound.io", Version: "v1beta1", Kind: "VPC"} + +func vpc(name, cidr string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "ec2.aws.upbound.io/v1beta1", + "kind": "VPC", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{"forProvider": map[string]any{"cidrBlock": cidr}}, + }} +} + +// fakeRouter routes every resource to the given client, or nowhere. +type fakeRouter struct { + client diffv1alpha1.PlanServiceClient +} + +func (f *fakeRouter) Route(schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + if f.client == nil { + return nil, false + } + return f.client, true +} + +// fakePlanClient answers Plan in-process. +type fakePlanClient struct { + rsp *diffv1alpha1.PlanResponse + err error + got *diffv1alpha1.PlanRequest +} + +func (f *fakePlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + f.got = in + return f.rsp, f.err +} + +func notSupportedErr(t *testing.T) error { + t.Helper() + st, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{{Type: "DIFF_COMPUTATION_NOT_SUPPORTED"}}, + }) + if err != nil { + t.Fatalf("cannot build status: %v", err) + } + return st.Err() +} + +func TestSimulateOne(t *testing.T) { + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + + cmd := &Cmd{Timeout: time.Second} + + t.Run("RoutedUpdate", func(t *testing.T) { + live := vpc("my-vpc", "10.0.0.0/16") + kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() + pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE}} + + got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, 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) + } + if pc.got.GetActualResource() == nil { + t.Errorf("simulateOne(...): want the live resource sent as actual state") + } + }) + + t.Run("NoLiveCounterpartPlansCreate", func(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}, vpc("new-vpc", "10.0.0.0/16")) + + if got.Action != plan.ActionCreate || got.Error != "" { + t.Errorf("simulateOne(...): want a create, got %+v", got) + } + if pc.got.GetActualResource() != nil { + t.Errorf("simulateOne(...): want no actual state for a missing live resource") + } + }) + + t.Run("NoRouteFallsBack", func(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{}, 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) + } + }) + + t.Run("NotSupportedFallsBack", func(t *testing.T) { + live := vpc("my-vpc", "10.0.0.0/16") + kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() + pc := &fakePlanClient{err: notSupportedErr(t)} + + got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, 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("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}, vpc("my-vpc", "10.1.0.0/16")) + + if got.Error == "" { + t.Errorf("simulateOne(...): want a per-resource error, got %+v", got) + } + }) + + t.Run("SeedsProviderConfigIntoObjectStore", func(t *testing.T) { + pcGVK := schema.GroupVersionKind{Group: "aws.upbound.io", Version: "v1beta1", Kind: "ProviderConfig"} + secretGVK := schema.GroupVersionKind{Version: "v1", Kind: "Secret"} + + seedScheme := runtime.NewScheme() + seedScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + seedScheme.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) + seedScheme.AddKnownTypeWithName(secretGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(secretGVK.GroupVersion().WithKind("SecretList"), &unstructured.UnstructuredList{}) + + seedRM := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion(), pcGVK.GroupVersion()}) + seedRM.Add(vpcGVK, meta.RESTScopeRoot) + seedRM.Add(pcGVK, meta.RESTScopeRoot) + + pc := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "aws.upbound.io/v1beta1", + "kind": "ProviderConfig", + "metadata": map[string]any{"name": "default"}, + "spec": map[string]any{"credentials": map[string]any{ + "source": "Secret", + "secretRef": map[string]any{"name": "aws-creds", "namespace": "crossplane-system", "key": "creds"}, + }}, + }} + secret := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "aws-creds", "namespace": "crossplane-system"}, + }} + + 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}, vpc("my-vpc", "10.0.0.0/16")) + + if got.Error != "" { + t.Fatalf("simulateOne(...): unexpected error: %s", got.Error) + } + if n := len(pc2.got.GetKubernetesObjectStore()); n != 1 { + t.Errorf("simulateOne(...): want the ProviderConfig in the object store, got %d objects", n) + } + if k := pc2.got.GetKubernetesObjectStore()[0].GetFields()["kind"].GetStringValue(); k != "ProviderConfig" { + t.Errorf("simulateOne(...): want a ProviderConfig in the object store, got %q", k) + } + }) + + t.Run("UnservedKindPlansCreate", func(t *testing.T) { + // A kind the cluster does not serve: no RESTMapping, no live fetch. + kube := fake.NewClientBuilder().WithScheme(s).Build() + emptyRM := meta.NewDefaultRESTMapper(nil) + pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} + + got := cmd.simulateOne(ctx, kube, emptyRM, "default", &fakeRouter{client: pc}, 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) + } + }) +} diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index 6f662497..7eed119c 100644 --- a/design/one-pager-cli-simulate.md +++ b/design/one-pager-cli-simulate.md @@ -39,20 +39,20 @@ external resource to update it, so applying such a change does not recreate anything, it wedges. The provider refuses the update, the MR sits unsynced, and a human has to intervene. Replacement, computed values, provider defaults, and custom diff logic are provider knowledge. A companion one-pager in -crossplane/upjet proposes exposing that knowledge: a `PlanService` protocol -that upjet providers serve from an `internal plan-server` subcommand, computing -the provider's own Terraform diff from a desired resource and its live state, -statelessly, with no cloud credentials and no cluster access. What that proposal -deliberately leaves to the client is orchestration: something has to figure out -which providers a change involves, run their plan servers, route resources to -them, and render the results. +crossplane/upjet proposed exposing that knowledge, and upjet has since merged +it: a `PlanService` protocol that upjet providers serve from an `internal +diff-server` subcommand, computing the provider's own Terraform diff from a +desired resource and its live state, statelessly, with no cloud credentials +and no cluster access. What that protocol deliberately leaves to the client is +orchestration: something has to figure out which providers a change involves, +run their diff servers, route resources to them, and render the results. The CLI is that client. It already does exactly this dance for functions: `crossplane composition render` pulls function packages, runs them as local Docker containers bound to ephemeral localhost ports, and drives them over gRPC. And with the project workflow now in the CLI (`crossplane project init|build|run`), the CLI knows a project's provider dependencies, so it -knows which plan servers to run without being told. +knows which diff servers to run without being told. ## Goals @@ -113,14 +113,14 @@ A Crossplane provider will do neither, so replaces get their own count instead of inflating actions that will not happen. For each input resource the CLI fetches the live MR from the cluster if one -exists (its `status.atProvider` and external-name annotation are the prior -state), sends desired and live to the routed plan server, and renders the -response. A resource with no live counterpart plans as a create "plan +exists, sends the desired resource and the whole live one to the routed diff +server (the live MR's `status.atProvider` and external-name annotation ride +along as the actual state), and renders the response. A resource with no live counterpart plans as a create "plan before apply". Which providers answer those plans comes from the current kubeconfig context by default: the CLI lists the providers installed on the target -cluster and runs their package images as plan servers, so the preview uses +cluster and runs their package images as diff servers, so the preview uses the provider versions the cluster actually runs. `--provider-images` is optional and overrides that discovery for the cases where the cluster is not the source of truth, most importantly the provider upgrade preview @@ -130,8 +130,8 @@ yet. ### Previewing a Provider Upgrade Provider upgrades fall out of the same command with no new machinery, -because the plan server *is* the provider version being asked about. To -preview an upgrade, run the new version's image as the plan server and +because the diff server *is* the provider version being asked about. To +preview an upgrade, run the new version's image as the diff server and hand it the fleet: ```console @@ -145,18 +145,19 @@ Plan: 0 to add, 3 to change, 0 to destroy, 1 requires replacement. 210 resources are unchanged under the new provider version. ``` -With `--all` the CLI enumerates instead of reading files: it asks the plan -server which API groups it serves (`GetInfo` again), lists every managed -resource of those kinds on the cluster, and sends each one with its own +With `--all` the CLI enumerates instead of reading files: it reads the +provider package's CRDs to learn which groups and kinds the new version +serves, lists every managed resource of those kinds on the cluster, and +sends each one with its own spec as desired state and its own live resource as prior state. The question each plan answers is "does the new provider version consider this resource up to date, exactly as it stands?". Anything that is not a no-op is a finding: a changed default, a schema migration, a new replacement -rule, a validation the next reconcile would trip over. Replaces and new -error diagnostics are the highest signal, because those are the resources -that wedge right after the upgrade. +rule, a validation the next reconcile would trip over. Replaces and +resources the new version refuses to plan are the highest signal, because +those are the resources that wedge right after the upgrade. -Because the plan server never refreshes, previewing the whole fleet costs +Because the diff server never refreshes, previewing the whole fleet costs zero cloud API calls and needs no cloud credentials: the CLI reads the cluster with the user's kubeconfig and everything else happens locally. That makes "plan the fleet before every provider bump" cheap enough to run @@ -184,7 +185,7 @@ The flow: each Composition composes. `--namespace` and `--name` scope the run. 2. **Prepare.** Build the project's embedded functions locally, exactly as `project run` does, so the simulation reflects the code in the working - tree. Resolve the project's provider dependencies and start their plan + tree. Resolve the project's provider dependencies and start their diff servers (below). 3. **Render.** For each XR, fetch its current composed resources from the cluster and re-render it with the *local* Composition and functions, @@ -204,7 +205,7 @@ The flow: detection so resources the new Composition no longer produces show as deletes. 5. **Plan the cloud layer.** For every rendered MR, merge in the live MR's - external-name and status, route it to a plan server by API group, and get + external-name and status, route it to a diff server by group and kind, and get the provider's verdict: no-op, update, or the replace nobody spotted in review. 6. **Report.** Per-XR diffs with per-resource actions, a @@ -280,10 +281,10 @@ type simulateCmd struct { Input []string `short:"f" help:"Project file (default), or changed Composition or XR files."` Namespace string `help:"Simulate only XRs in this namespace."` Name string `help:"Simulate only the XR with this name."` - ProviderImages []string `help:"Override the provider images to run as plan servers."` + ProviderImages []string `help:"Override the provider images to run as diff servers."` IncludePinned bool `help:"Also simulate XRs pinned to older composition revisions."` SkipPlan bool `help:"Skip the cloud-layer plan; show only the cluster-layer diff."` - KeepPlanServers bool `help:"Leave plan server containers running for reuse by later runs."` + KeepDiffServers bool `help:"Leave diff server containers running for reuse by later runs."` MaxIterations uint `default:"10" help:"Cap on requirement resolution passes per XR."` Timeout time.Duration `default:"2m" help:"Per XR render and plan timeout."` MaxConcurrency uint `default:"8" help:"XRs simulated concurrently."` @@ -291,12 +292,12 @@ type simulateCmd struct { } ``` -`crossplane resource simulate` shares the plan server, timeout, and output +`crossplane resource simulate` shares the diff server, timeout, and output flags, and takes its resources from `-f` files instead of project discovery. Both commands enter the tree with the CLI's `maturity:"alpha"` tag and graduate the way every other command does. -### Running Plan Servers Like Render Runs Functions +### Running Diff Servers Like Render Runs Functions The CLI treats provider packages the way render treats function packages: as images it can run locally and talk to over gRPC. @@ -305,37 +306,44 @@ The provider images come from the project's `dependsOn` for `project simulate`, from the providers installed on the current cluster context for `resource simulate`, or from an explicit `--provider-images` override of either. The CLI starts each provider -image with its `internal plan-server` entrypoint as a local Docker +image with its `internal diff-server` entrypoint as a local Docker container, following the same runtime conventions render established for -functions (pull policy, cleanup on exit). How the CLI talks to that -container, a gRPC connection on an ephemeral localhost port or a protobuf -exchange over the container's stdin and stdout, is part of the plan service -contract, so the upjet one-pager decides it and this document follows. The -exchange is the same either way: the CLI first asks each server `GetInfo`, -which returns the API groups the server can plan (`s3.aws.upbound.io`, -`kms.aws.upbound.io`, ...), and builds a routing table from the answer. -Routing by declared capability rather than image-name convention means -family providers coexist and a resource with no route is detected up -front, not by a failed call. +functions (pull policy, cleanup on exit), and talks to it over plaintext +gRPC on an ephemeral localhost port, as upjet's merged protocol defines +(the server can also bind a unix socket, and registers gRPC reflection, +which doubles as the readiness probe). What a server can plan is declared +before any container starts: a provider package that serves diffs carries +the `DiffServer` capability in its package metadata (`spec.capabilities`), +and the CRDs inside the package say exactly which groups and kinds that +provider serves. The CLI checks the capability first, starts containers +only for packages that have it, and builds its routing table from the +packages' CRDs, by group and kind. Routing by declared package contents +rather than image-name convention means family providers coexist and a +resource with no route is detected up front, not by a failed call. The +protocol is alpha, merged to upjet's main branch ahead of a release, and +simulate enters the CLI as alpha alongside it. That split is what makes the `--provider-images` override simple: the flag only changes where the image list comes from, never how routing works. The -CLI never needs to know up front which image serves which API group, not -from a registry, not from image names, not from cluster state. It starts -whatever images the list contains and lets each server declare its groups -through `GetInfo`. Override the list with a new provider version and the -routing table rebuilds itself from what that version actually serves. - -Because plan servers are stateless and credential-free, all state travels +CLI never hardcodes which image serves which group and kind, not from +image names, not from cluster state. It inspects whatever images the list +contains and lets each package declare its capability and its kinds +through its own metadata. Override the list with a new provider version +and the routing table rebuilds itself from what that version actually +ships. + +Because diff servers are stateless and credential-free, all state travels in the request. There is nothing to configure: no ProviderConfig, no cloud -credentials, no reach into the cluster from the container. The -CLI fetches live state with the user's own kubeconfig and RBAC; the plan -server only ever sees what the user could already read. +credentials, no reach into the cluster from the container. The CLI fetches +live state, and the Secrets or referenced objects a plan may need to +resolve, with the user's own kubeconfig and RBAC, and sends them along in +the request; the diff server only ever sees what the user could already +read. -Startup is the one cost that hurts across runs: a plan server takes a few +Startup is the one cost that hurts across runs: a diff server takes a few seconds to come up, and an edit-simulate-edit loop pays that on every run if containers are torn down at exit. So containers get deterministic -names derived from the provider image digest, and `--keep-plan-servers` +names derived from the provider image digest, and `--keep-diff-servers` leaves them running when the command exits: the next run finds a live server with a matching name and reuses it instead of starting a new one. Because the name carries the digest, a changed image never matches a @@ -347,12 +355,17 @@ but that belongs to render. ### From Response to Printed Diff -What a plan server returns is deliberately close to what the CLI prints. -Every response carries the action, the field changes with current and -desired values, replacement information, and provider diagnostics, and -every field path arrives as a CRD path -`spec.forProvider.deletionWindowInDays`. -The server also redacts sensitive values and marks computed ones. +What a diff server returns is deliberately close to what the CLI prints. +Every response carries the action and the field changes with current and +planned values, each change marked with whether it forces replacement and +whether it originates from the desired state or from the provider +(defaults and late-initialized values), and every field path arrives as a +CRD path `spec.forProvider.deletionWindowInDays`. The server also redacts +sensitive values, marks computed ones as known only after apply, and marks +values it cannot resolve, an unresolved reference or Secret, rather than +guessing. Failures are not part of the response: a server that cannot plan +a resource says so through its error status, and simulate turns that into +the approximate fallback below. Before sending, the CLI has to answer which live resource a rendered one corresponds to, and names are not enough: a re-render can generate fresh @@ -369,9 +382,9 @@ plan as a delete instead of silently disappearing from the preview. Printing then follows one rule: show users their own manifest, annotated. Each resource prints as YAML in CRD terms with changed fields marked inline (`~ field: "old" -> "new"`), sensitive values stay redacted, computed -values print as `(known after apply)`, and fields that force replacement -carry that warning on the line where the user's eye already is. Provider -diagnostics print under the resource they belong to. Actions map to the +values print as `(known after apply)`, unresolved references print as +`(unresolved reference)`, and fields that force replacement carry that +warning on the line where the user's eye already is. Actions map to the summary symbols (`[+]` create, `[~]` update, `[-/+]` replace, `[-]` delete, `[=]` no-op, `[?]` approximate fallback). @@ -381,25 +394,28 @@ the consumer, not the tool: `-o diff` prints the same result as a line-by-line unified diff (`- foo: bar` / `+ foo: baz`), the format crossplane-diff emits today, which renders directly in Markdown `diff` code blocks for CI comments. What only the plan layer knows (replacement, -known-after-apply, diagnostics) prints in the per-resource header and the +known-after-apply values) prints in the per-resource header and the summary in diff mode, since those have no inline line to attach to. The same data serializes as `-o json|yaml`, one typed result per resource, so a pull-request bot can gate on "any replace anywhere" without parsing console output. -### When There Is No Plan Server +### When There Is No Diff Server Not every provider is an upjet provider, and not every upjet provider will serve plans on day one. Simulate treats planning as a capability it detects, not a requirement: -* If a resource routes to a plan server, its diff is provider-computed and +* If a resource routes to a diff server, its diff is provider-computed and accurate: replacement detection, known-after-apply values, sensitive-value redaction. -* If it does not, the CLI falls back to a client-side comparison of desired - against live spec, rendered with a distinct marker (`[?]`) and a closing - warning that the result is approximate: no replacement detection, no - computed values. +* A routed server can still refuse an individual resource it cannot compute + a diff for; the server says so explicitly in its error status, and the + resource takes the fallback below instead of failing the run. +* If a resource routes nowhere, or its server refused it, the CLI falls + back to a client-side comparison of desired against live spec, rendered + with a distinct marker (`[?]`) and a closing warning that the result is + approximate: no replacement detection, no computed values. A mixed control plane gets a complete preview that is honest about which parts are provider-grade, and every provider that adopts the plan protocol @@ -453,11 +469,11 @@ standalone packaging forced them to build. and `--name` to scope the cluster side), and both gain the plan layer as their upgrade. That upgrade is strictly additive, so today's diff functionality is subsumed rather than replaced: planning is a capability - simulate detects per resource, and a resource with no plan server gets + simulate detects per resource, and a resource with no diff server gets the client-side diff crossplane-diff computes today ("When There Is No - Plan Server" above). Run simulate where no provider serves plans and the + Diff Server" above). Run simulate where no provider serves plans and the output is today's diff, unchanged. For users who want only the - cluster-layer diff, `--skip-plan` makes that explicit: no plan servers + cluster-layer diff, `--skip-plan` makes that explicit: no diff servers start, no containers run, and the command does exactly what diff does today. That keeps "diff without simulation" one flag away instead of a second command. Whether thin aliases remain for existing crossplane-diff @@ -483,11 +499,11 @@ of the control plane. Per XR: the same render work `crossplane render` does (plus requirement-resolution iterations, capped), one server-side dry-run per composed resource, and one plan computation per rendered MR, each of which is a pure in-process schema diff on the server, no cloud calls. -(Whether the protocol carries those computations one per request or -batched is a plan protocol question, decided in the upjet one-pager.) -Plan-server startup is the dominant fixed cost, a few seconds per distinct +The protocol carries one resource per unary request; a fleet preview is +many small requests, each cheap. +Diff-server startup is the dominant fixed cost, a few seconds per distinct provider image, paid once per run and torn down after, or paid once across -many runs with `--keep-plan-servers`. Nothing simulate does +many runs with `--keep-diff-servers`. Nothing simulate does writes to the cluster or the cloud: reads plus dry-runs on one side, a credential-free diff on the other. diff --git a/flake.lock b/flake.lock index 94721b95..bff701fb 100644 --- a/flake.lock +++ b/flake.lock @@ -18,11 +18,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1789370336, - "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=", + "lastModified": 1791637130, + "narHash": "sha256-TuvwvSjJrpqyIYdjhQ7Wu+ADVfeOpO2Um21/V7TOubw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c7def046b9a883d46974757852106483d741586f", + "rev": "ca1659429c950a11a565b5764e37a1b704e17ec8", "type": "github" }, "original": { diff --git a/go.mod b/go.mod index bfabe1df..17a3abda 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/crossplane/cli/v2 -go 1.26.0 +go 1.26.8 require ( charm.land/bubbles/v2 v2.2.1 @@ -13,8 +13,9 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/containerd/errdefs v1.0.0 github.com/crossplane/crossplane-runtime/v2 v2.4.0 - github.com/crossplane/crossplane/apis/v2 v2.4.0 + github.com/crossplane/crossplane/apis/v2 v2.4.1 github.com/crossplane/function-sdk-go v0.7.1 + github.com/crossplane/upjet/v2 v2.5.1-0.20261005082904-c3b7d449d0d4 github.com/docker/cli v29.8.1+incompatible github.com/emicklei/dot v1.11.0 github.com/getkin/kin-openapi v0.149.0 @@ -38,6 +39,7 @@ require ( golang.org/x/sync v0.22.0 golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -52,7 +54,7 @@ require ( k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 k8s.io/metrics v0.35.1 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/kind v0.30.0 sigs.k8s.io/yaml v1.6.0 ) @@ -281,7 +283,7 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yuin/goldmark v1.7.17 // indirect + github.com/yuin/goldmark v1.8.4 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect @@ -307,7 +309,6 @@ require ( golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index de06ec2d..f927e28b 100644 --- a/go.sum +++ b/go.sum @@ -228,10 +228,12 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crossplane/crossplane-runtime/v2 v2.4.0 h1:m5iGVHbtEh9nZYz7w/wUWDsXVlpG2c8v5i0SZScwHh8= github.com/crossplane/crossplane-runtime/v2 v2.4.0/go.mod h1:Pf+hg5A/46QaGjKi7Pk+HdsrFOA/THvST9qM4El8Rzs= -github.com/crossplane/crossplane/apis/v2 v2.4.0 h1:QmZqZe+vvi9IrnEK8rW7UsCDQBqagK6HFV1mYEw/DkQ= -github.com/crossplane/crossplane/apis/v2 v2.4.0/go.mod h1:Ev+FjQqTTWJWoaXi63uKER215kDjTo8PghDCT0JapZs= +github.com/crossplane/crossplane/apis/v2 v2.4.1 h1:ye1vpLjSDicKQbKnMzPjzWNxwOdqYVlKDWSHt9VVz5g= +github.com/crossplane/crossplane/apis/v2 v2.4.1/go.mod h1:OBNx+tl5RQh31Hqo2Q1KZ5tlwZXUIetAvpKKozP8HhU= github.com/crossplane/function-sdk-go v0.7.1 h1:BEM2b7nAr8rJYc9tjwnoMfVVy1veCA/juD8APYCVLi0= github.com/crossplane/function-sdk-go v0.7.1/go.mod h1:AOD9l/tlKYP6giyT8ZVxrcTUCYliRDKO3aRySojsHVY= +github.com/crossplane/upjet/v2 v2.5.1-0.20261005082904-c3b7d449d0d4 h1:EBkXDtRqz4kx8lDgiUqstneHP20VNH1UWJD1TvbgBLM= +github.com/crossplane/upjet/v2 v2.5.1-0.20261005082904-c3b7d449d0d4/go.mod h1:NQQKxhgkTx7PFsy3V3EIZoeKKg024eQMZp4tyW/TW18= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -818,8 +820,8 @@ github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= -github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= @@ -1082,8 +1084,8 @@ pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= -sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8= sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/diffserver/manager.go b/internal/diffserver/manager.go new file mode 100644 index 00000000..9d0278e2 --- /dev/null +++ b/internal/diffserver/manager.go @@ -0,0 +1,378 @@ +/* +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 diffserver runs provider packages as local diff server containers +// and routes resources to them by group and kind, the way render runs +// function packages. Whether a package can serve diffs is declared in its +// metadata (the DiffServer capability), and which resources it serves comes +// from the CRDs it ships, so everything is known before a container starts. +package diffserver + +import ( + "context" + "fmt" + "net/netip" + "strings" + "sync" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + + "github.com/crossplane/cli/v2/internal/docker" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + // CapabilityDiffServer is the package capability a provider declares when + // its image serves the diff protocol via `internal diff-server`. + CapabilityDiffServer = "DiffServer" + + // containerPort is the port the diff server binds inside the container, + // upjet's default for `internal diff-server --address`. + containerPort = 9099 + + // containerPrefix names diff server containers. The full name carries the + // package digest, so a changed image never matches a stale container and + // leftovers from --keep-diff-servers are easy to find. + containerPrefix = "crossplane-diff-server-" + + readyPollInterval = 500 * time.Millisecond + defaultReadyTimeout = 60 * time.Second +) + +// Server is a running diff server for one provider package. +type Server struct { + Image string + Address string + ContainerID string + GroupKinds []schema.GroupKind + + conn *grpc.ClientConn + client diffv1alpha1.PlanServiceClient + reused bool +} + +// Skipped records a provider image that does not run a diff server this run, +// and why. Resources it would have served fall back to the approximate +// client-side diff. +type Skipped struct { + Image string + Reason string +} + +// containerRuntime is the slice of internal/docker the manager uses, +// extracted so tests can fake container lifecycle. +type containerRuntime interface { + StartContainer(ctx context.Context, name, image string, opts ...docker.StartContainerOption) (string, error) + StartContainerByID(ctx context.Context, id string) error + StopContainerByID(ctx context.Context, id string) error + GetRunningContainerIDByName(ctx context.Context, name string) (id string, found, running bool, err error) + GetHostPort(ctx context.Context, id string, port int) (string, error) +} + +// Manager starts diff server containers for provider packages and routes +// resources to them. +type Manager struct { + pkgs runtimexpkg.Client + rt containerRuntime + log logging.Logger + keep bool + readyTimeout time.Duration + + mu sync.Mutex + routes map[schema.GroupKind]*Server + servers []*Server +} + +// Option configures a Manager. +type Option func(*Manager) + +// WithKeepServers leaves diff server containers running when the manager +// stops, so later runs find and reuse them by name. +func WithKeepServers(keep bool) Option { + return func(m *Manager) { m.keep = keep } +} + +// WithContainerRuntime replaces the container runtime, for testing. +func WithContainerRuntime(rt containerRuntime) Option { + return func(m *Manager) { m.rt = rt } +} + +// WithReadyTimeout sets how long to wait for a started diff server to answer. +func WithReadyTimeout(d time.Duration) Option { + return func(m *Manager) { m.readyTimeout = d } +} + +// NewManager returns a Manager that fetches provider packages with pkgs and +// runs their images as local diff server containers. +func NewManager(pkgs runtimexpkg.Client, log logging.Logger, opts ...Option) *Manager { + m := &Manager{ + pkgs: pkgs, + rt: dockerRuntime{}, + log: log, + readyTimeout: defaultReadyTimeout, + routes: map[schema.GroupKind]*Server{}, + } + for _, opt := range opts { + opt(m) + } + return m +} + +// Start inspects each provider image and runs a diff server container for +// every package that declares the DiffServer capability, in parallel and best +// effort: an image that cannot serve (no capability, fetch or startup +// failure) is returned as skipped rather than failing the run, since its +// resources degrade to the approximate client-side diff. +func (m *Manager) Start(ctx context.Context, images []string) ([]Skipped, error) { + var ( + wg sync.WaitGroup + mu sync.Mutex + skipped []Skipped + ) + for _, img := range images { + wg.Add(1) + go func(img string) { + defer wg.Done() + s, skip := m.startOne(ctx, img) + mu.Lock() + defer mu.Unlock() + if skip != nil { + skipped = append(skipped, *skip) + return + } + m.register(s) + }(img) + } + wg.Wait() + return skipped, ctx.Err() +} + +func (m *Manager) register(s *Server) { + m.mu.Lock() + defer m.mu.Unlock() + m.servers = append(m.servers, s) + for _, gk := range s.GroupKinds { + if other, ok := m.routes[gk]; ok { + m.log.Info("Multiple diff servers serve the same kind; keeping the first", + "groupKind", gk.String(), "kept", other.Image, "ignored", s.Image) + continue + } + m.routes[gk] = s + } +} + +func (m *Manager) startOne(ctx context.Context, image string) (*Server, *Skipped) { + pkg, err := m.pkgs.Get(ctx, image) + if err != nil { + return nil, &Skipped{Image: image, Reason: fmt.Sprintf("cannot fetch package: %s", err)} + } + + meta := pkg.GetMeta() + if meta == nil || !pkgmetav1.CapabilitiesContainFuzzyMatch(meta.GetCapabilities(), CapabilityDiffServer) { + return nil, &Skipped{Image: image, Reason: "package does not declare the DiffServer capability"} + } + + gks := clixpkg.GroupKinds(pkg.Package) + if len(gks) == 0 { + return nil, &Skipped{Image: image, Reason: "package contains no CRDs to route"} + } + + id, reused, err := m.ensureContainer(ctx, containerName(pkg.Digest), image) + if err != nil { + return nil, &Skipped{Image: image, Reason: fmt.Sprintf("cannot start diff server container: %s", err)} + } + + server, err := m.connect(ctx, id, image) + if err != nil { + if !reused && !m.keep { + if serr := m.rt.StopContainerByID(ctx, id); serr != nil { + m.log.Info("Cannot clean up failed diff server container", "container", id, "err", serr) + } + } + return nil, &Skipped{Image: image, Reason: err.Error()} + } + + server.GroupKinds = gks + server.reused = reused + m.log.Debug("Diff server ready", "image", image, "address", server.Address, "reused", reused) + return server, nil +} + +// ensureContainer finds a live container with the deterministic name for this +// package digest and reuses it, or starts a new one. +func (m *Manager) ensureContainer(ctx context.Context, name, image string) (id string, reused bool, err error) { + id, found, running, err := m.rt.GetRunningContainerIDByName(ctx, name) + if err != nil { + return "", false, err + } + if found { + if !running { + if err := m.rt.StartContainerByID(ctx, id); err != nil { + return "", false, err + } + } + return id, true, nil + } + + id, err = m.rt.StartContainer(ctx, name, image, + // --debug precedes the subcommand: it is the provider's global flag. + docker.StartWithCommand([]string{"--debug", "internal", "diff-server", "--address", fmt.Sprintf(":%d", containerPort)}), + docker.StartWithPublishedPort(netip.MustParseAddr("127.0.0.1"), containerPort), + ) + return id, false, err +} + +func (m *Manager) connect(ctx context.Context, containerID, image string) (*Server, error) { + addr, err := m.rt.GetHostPort(ctx, containerID, containerPort) + if err != nil { + return nil, errors.Wrap(err, "cannot determine diff server address") + } + + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, errors.Wrap(err, "cannot create gRPC client") + } + + client := diffv1alpha1.NewPlanServiceClient(conn) + if err := waitReady(ctx, client, m.readyTimeout); err != nil { + _ = conn.Close() + return nil, err + } + + return &Server{ + Image: image, + Address: addr, + ContainerID: containerID, + conn: conn, + client: client, + }, nil +} + +// waitReady polls the server with an empty Plan request until it answers. +// Docker publishes the host port before the server inside the container binds +// its own, so a successful TCP connect proves nothing; any gRPC status from +// the server itself (an empty request draws INVALID_ARGUMENT) means it is +// serving. +func waitReady(ctx context.Context, c diffv1alpha1.PlanServiceClient, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var last error + for { + _, err := c.Plan(ctx, &diffv1alpha1.PlanRequest{}) + if err == nil { + return nil + } + if s, ok := status.FromError(err); ok && s.Code() != codes.Unavailable && s.Code() != codes.DeadlineExceeded && s.Code() != codes.Canceled { + return nil + } + last = err + + select { + case <-ctx.Done(): + return errors.Wrap(last, "timed out waiting for diff server to become ready") + case <-time.After(readyPollInterval): + } + } +} + +// Route returns the diff server client that serves the given group and kind, +// if any. +func (m *Manager) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.routes[gk] + if !ok { + return nil, false + } + return s.client, true +} + +// Servers returns the running diff servers. +func (m *Manager) Servers() []*Server { + m.mu.Lock() + defer m.mu.Unlock() + return append([]*Server(nil), m.servers...) +} + +// Stop closes connections and tears down containers. With keep set the +// containers stay running so the next run finds them by name and skips +// startup. +func (m *Manager) Stop(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + var errs []error + for _, s := range m.servers { + if s.conn != nil { + _ = s.conn.Close() + } + if m.keep { + continue + } + if err := m.rt.StopContainerByID(ctx, s.ContainerID); err != nil { + errs = append(errs, errors.Wrapf(err, "cannot stop diff server container %s", s.ContainerID)) + } + } + return errors.Join(errs...) +} + +func containerName(digest string) string { + d := strings.TrimPrefix(digest, "sha256:") + if len(d) > 12 { + d = d[:12] + } + return containerPrefix + d +} + +// dockerRuntime implements containerRuntime with the real Docker daemon. +type dockerRuntime struct{} + +func (dockerRuntime) StartContainer(ctx context.Context, name, image string, opts ...docker.StartContainerOption) (string, error) { + return docker.StartContainer(ctx, name, image, opts...) +} + +func (dockerRuntime) StartContainerByID(ctx context.Context, id string) error { + return docker.StartContainerByID(ctx, id) +} + +func (dockerRuntime) StopContainerByID(ctx context.Context, id string) error { + return docker.StopContainerByID(ctx, id) +} + +func (dockerRuntime) GetRunningContainerIDByName(ctx context.Context, name string) (string, bool, bool, error) { + c, found, err := docker.GetContainerByName(ctx, name, true) + if err != nil || !found { + return "", false, false, err + } + return c.ID, true, strings.EqualFold(string(c.State), "running"), nil +} + +func (dockerRuntime) GetHostPort(ctx context.Context, id string, port int) (string, error) { + return docker.GetHostPort(ctx, id, port) +} diff --git a/internal/diffserver/manager_test.go b/internal/diffserver/manager_test.go new file mode 100644 index 00000000..705bc4a8 --- /dev/null +++ b/internal/diffserver/manager_test.go @@ -0,0 +1,340 @@ +/* +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 diffserver + +import ( + "context" + "io" + "net" + "strings" + "sync" + "testing" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + + "github.com/crossplane/cli/v2/internal/docker" +) + +const ( + pkgWithCapability = `--- +apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-aws-ec2 +spec: + capabilities: + - SafeStart + - DiffServer +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: vpcs.ec2.aws.upbound.io +spec: + group: ec2.aws.upbound.io + names: + kind: VPC + plural: vpcs + scope: Cluster + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: vpcs.ec2.aws.m.upbound.io +spec: + group: ec2.aws.m.upbound.io + names: + kind: VPC + plural: vpcs + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +` + + pkgWithoutCapability = `--- +apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-aws-s3 +spec: + capabilities: + - SafeStart +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: buckets.s3.aws.upbound.io +spec: + group: s3.aws.upbound.io + names: + kind: Bucket + plural: buckets + scope: Cluster + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +` +) + +func parsePackage(t *testing.T, yamlDoc, digest string) *runtimexpkg.Package { + t.Helper() + + metaScheme, err := runtimexpkg.BuildMetaScheme() + if err != nil { + t.Fatalf("cannot build meta scheme: %v", err) + } + objScheme, err := runtimexpkg.BuildObjectScheme() + if err != nil { + t.Fatalf("cannot build object scheme: %v", err) + } + + pkg, err := parser.New(metaScheme, objScheme).Parse(context.Background(), io.NopCloser(strings.NewReader(yamlDoc))) + if err != nil { + t.Fatalf("cannot parse test package: %v", err) + } + return &runtimexpkg.Package{Package: pkg, Digest: digest} +} + +// fakePkgClient implements the runtime xpkg.Client interface with canned +// packages. +type fakePkgClient struct { + pkgs map[string]*runtimexpkg.Package +} + +func (f *fakePkgClient) Get(_ context.Context, ref string, _ ...runtimexpkg.GetOption) (*runtimexpkg.Package, error) { + p, ok := f.pkgs[ref] + if !ok { + return nil, status.Error(codes.NotFound, "no such package") + } + return p, nil +} + +func (f *fakePkgClient) ListVersions(_ context.Context, _ string, _ ...runtimexpkg.GetOption) ([]string, error) { + return nil, nil +} + +// fakeRuntime fakes container lifecycle, pointing every container at addr. +type fakeRuntime struct { + mu sync.Mutex + addr string + existing map[string]fakeContainer // name -> container + started []string // names passed to StartContainer + stopped []string // ids passed to StopContainerByID +} + +type fakeContainer struct { + id string + running bool +} + +func (f *fakeRuntime) StartContainer(_ context.Context, name, _ string, _ ...docker.StartContainerOption) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.started = append(f.started, name) + return "new-" + name, nil +} + +func (f *fakeRuntime) StopContainerByID(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopped = append(f.stopped, id) + return nil +} + +func (f *fakeRuntime) StartContainerByID(_ context.Context, _ string) error { return nil } + +func (f *fakeRuntime) GetRunningContainerIDByName(_ context.Context, name string) (string, bool, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + c, ok := f.existing[name] + if !ok { + return "", false, false, nil + } + return c.id, true, c.running, nil +} + +func (f *fakeRuntime) GetHostPort(_ context.Context, _ string, _ int) (string, error) { + return f.addr, nil +} + +// readyServer serves a real PlanService on a loopback listener that answers +// every request with INVALID_ARGUMENT, which is all readiness needs. +type readyServer struct { + diffv1alpha1.UnimplementedPlanServiceServer +} + +func (readyServer) Plan(context.Context, *diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return nil, status.Error(codes.InvalidArgument, "empty request") +} + +func servePlanService(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("cannot listen: %v", err) + } + s := grpc.NewServer() + diffv1alpha1.RegisterPlanServiceServer(s, readyServer{}) + go func() { _ = s.Serve(lis) }() + t.Cleanup(s.Stop) + return lis.Addr().String() +} + +func TestManagerStart(t *testing.T) { + ctx := context.Background() + log := logging.NewNopLogger() + + pkgs := &fakePkgClient{pkgs: map[string]*runtimexpkg.Package{ + "xpkg.example.org/provider-aws-ec2:v2.9.0": parsePackage(t, pkgWithCapability, "sha256:0123456789abcdef"), + "xpkg.example.org/provider-aws-s3:v2.9.0": parsePackage(t, pkgWithoutCapability, "sha256:fedcba9876543210"), + }} + + t.Run("NoCapabilitySkipsWithoutStarting", func(t *testing.T) { + rt := &fakeRuntime{} + m := NewManager(pkgs, log, WithContainerRuntime(rt)) + + skipped, err := m.Start(ctx, []string{"xpkg.example.org/provider-aws-s3:v2.9.0"}) + if err != nil { + t.Fatalf("Start(...): unexpected error: %v", err) + } + if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "DiffServer capability") { + t.Errorf("Start(...): want one capability skip, got %+v", skipped) + } + if len(rt.started) != 0 { + t.Errorf("Start(...): no container should start for a package without the capability") + } + }) + + t.Run("RoutesBuiltFromPackageCRDs", func(t *testing.T) { + rt := &fakeRuntime{addr: servePlanService(t)} + m := NewManager(pkgs, log, WithContainerRuntime(rt)) + + skipped, err := m.Start(ctx, []string{"xpkg.example.org/provider-aws-ec2:v2.9.0"}) + if err != nil { + t.Fatalf("Start(...): unexpected error: %v", err) + } + if len(skipped) != 0 { + t.Fatalf("Start(...): unexpected skips: %+v", skipped) + } + + for _, gk := range []schema.GroupKind{ + {Group: "ec2.aws.upbound.io", Kind: "VPC"}, + {Group: "ec2.aws.m.upbound.io", Kind: "VPC"}, + } { + if _, ok := m.Route(gk); !ok { + t.Errorf("Route(%s): want a route from the package CRDs", gk) + } + } + if _, ok := m.Route(schema.GroupKind{Group: "s3.aws.upbound.io", Kind: "Bucket"}); ok { + t.Errorf("Route(Bucket.s3): want no route for a kind the package does not ship") + } + + wantName := containerPrefix + "0123456789ab" + if len(rt.started) != 1 || rt.started[0] != wantName { + t.Errorf("Start(...): want container %q started, got %v", wantName, rt.started) + } + + if err := m.Stop(ctx); err != nil { + t.Fatalf("Stop(...): unexpected error: %v", err) + } + if len(rt.stopped) != 1 { + t.Errorf("Stop(...): want the container stopped, got %v", rt.stopped) + } + }) + + t.Run("ReusesRunningContainerByName", func(t *testing.T) { + rt := &fakeRuntime{ + addr: servePlanService(t), + existing: map[string]fakeContainer{ + containerPrefix + "0123456789ab": {id: "existing-id", running: true}, + }, + } + m := NewManager(pkgs, log, WithContainerRuntime(rt)) + + skipped, err := m.Start(ctx, []string{"xpkg.example.org/provider-aws-ec2:v2.9.0"}) + if err != nil || len(skipped) != 0 { + t.Fatalf("Start(...): unexpected error or skips: %v %+v", err, skipped) + } + if len(rt.started) != 0 { + t.Errorf("Start(...): want the existing container reused, got new containers %v", rt.started) + } + }) + + t.Run("KeepLeavesContainersRunning", func(t *testing.T) { + rt := &fakeRuntime{addr: servePlanService(t)} + m := NewManager(pkgs, log, WithContainerRuntime(rt), WithKeepServers(true)) + + if _, err := m.Start(ctx, []string{"xpkg.example.org/provider-aws-ec2:v2.9.0"}); err != nil { + t.Fatalf("Start(...): unexpected error: %v", err) + } + if err := m.Stop(ctx); err != nil { + t.Fatalf("Stop(...): unexpected error: %v", err) + } + if len(rt.stopped) != 0 { + t.Errorf("Stop(...): want containers left running with keep, got stops %v", rt.stopped) + } + }) + + t.Run("NotReadyStopsContainerAndSkips", func(t *testing.T) { + // A listener that accepts no gRPC: readiness never succeeds. + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("cannot listen: %v", err) + } + t.Cleanup(func() { _ = lis.Close() }) + + rt := &fakeRuntime{addr: lis.Addr().String()} + m := NewManager(pkgs, log, WithContainerRuntime(rt), WithReadyTimeout(700*time.Millisecond)) + + skipped, err := m.Start(ctx, []string{"xpkg.example.org/provider-aws-ec2:v2.9.0"}) + if err != nil { + t.Fatalf("Start(...): unexpected error: %v", err) + } + if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "ready") { + t.Errorf("Start(...): want a readiness skip, got %+v", skipped) + } + if len(rt.stopped) != 1 { + t.Errorf("Start(...): want the unready container cleaned up, got stops %v", rt.stopped) + } + }) +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 0aab565d..cbc2948e 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -26,6 +26,8 @@ import ( "encoding/json" "fmt" "io" + "net" + "net/netip" "path" "path/filepath" "slices" @@ -267,6 +269,54 @@ func StartWithCopyFiles(tarball []byte, path string) StartContainerOption { } } +// StartWithPublishedPort publishes the container's TCP containerPort on an +// ephemeral port on hostIP; the engine allocates the host port. Use +// GetHostPort after the container starts to learn the allocated address. +func StartWithPublishedPort(hostIP netip.Addr, containerPort int) StartContainerOption { + return func(cfg *startContainerConfig) { + port := network.MustParsePort(fmt.Sprintf("%d/tcp", containerPort)) + if cfg.containerConfig.ExposedPorts == nil { + cfg.containerConfig.ExposedPorts = network.PortSet{} + } + cfg.containerConfig.ExposedPorts[port] = struct{}{} + if cfg.hostConfig == nil { + cfg.hostConfig = &container.HostConfig{} + } + if cfg.hostConfig.PortBindings == nil { + cfg.hostConfig.PortBindings = network.PortMap{} + } + cfg.hostConfig.PortBindings[port] = []network.PortBinding{{ + HostIP: hostIP, + HostPort: "0", // "0" => engine allocates an ephemeral port + }} + } +} + +// GetHostPort returns the host address (host:port) a container's published +// TCP containerPort is bound to. +func GetHostPort(ctx context.Context, containerID string, containerPort int) (string, error) { + cli, err := NewClient() + if err != nil { + return "", err + } + + inspect, err := cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + return "", errors.Wrap(err, "cannot inspect container") + } + + port := network.MustParsePort(fmt.Sprintf("%d/tcp", containerPort)) + bindings := inspect.Container.NetworkSettings.Ports[port] + if len(bindings) == 0 { + return "", errors.Errorf("container %q has no published binding for port %s", containerID, port.Port()) + } + binding := bindings[0] + if !binding.HostIP.IsValid() { + return "", errors.Errorf("container %q has a port binding for %s but no host address", containerID, port.Port()) + } + return net.JoinHostPort(binding.HostIP.String(), binding.HostPort), nil +} + // StartWithEnv adds environment variables that will be passed to the container. func StartWithEnv(env ...string) StartContainerOption { return func(cfg *startContainerConfig) { diff --git a/internal/plan/fallback.go b/internal/plan/fallback.go new file mode 100644 index 00000000..f3baa652 --- /dev/null +++ b/internal/plan/fallback.go @@ -0,0 +1,157 @@ +/* +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 plan + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "slices" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Fallback computes an approximate client-side plan for desired by comparing +// its spec against the live resource's spec. It is used when no diff server +// can plan the resource, and is honest about its limits: no replacement +// detection, no computed values, no provider defaults. live nil means the +// resource does not exist and plans as a create. +func Fallback(desired, live *unstructured.Unstructured, reason string) *ResourcePlan { + p := NewResourcePlan(desired) + p.Approximate = true + p.FallbackReason = reason + + desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec") + + if live == nil { + p.Action = ActionCreate + p.Changes = createChanges("spec", desiredSpec) + return p + } + + liveSpec, _, _ := unstructured.NestedMap(live.Object, "spec") + p.Changes = diffMaps("spec", liveSpec, desiredSpec) + if len(p.Changes) == 0 { + p.Action = ActionNoOp + return p + } + p.Action = ActionUpdate + return p +} + +// createChanges flattens a desired map into planned-only field changes, so a +// create lists what it would set. +func createChanges(path string, m map[string]any) []FieldChange { + out := make([]FieldChange, 0, len(m)) + for _, k := range slices.Sorted(maps.Keys(m)) { + out = append(out, flattenValue(path+"."+k, m[k], false)...) + } + return out +} + +// flattenValue emits one planned-only (or actual-only, for removals) change +// per leaf under path. +func flattenValue(path string, v any, removed bool) []FieldChange { + switch t := v.(type) { + case map[string]any: + out := make([]FieldChange, 0, len(t)) + for _, k := range slices.Sorted(maps.Keys(t)) { + out = append(out, flattenValue(path+"."+k, t[k], removed)...) + } + return out + case []any: + out := make([]FieldChange, 0, len(t)) + for i, e := range t { + out = append(out, flattenValue(fmt.Sprintf("%s[%d]", path, i), e, removed)...) + } + return out + default: + fc := FieldChange{Path: path} + if removed { + fc.Actual = &Value{Value: v} + } else { + fc.Planned = &Value{Value: v} + } + return []FieldChange{fc} + } +} + +// diffMaps compares two maps recursively and returns the changes that turn +// actual into planned, with paths rooted at path. +func diffMaps(path string, actual, planned map[string]any) []FieldChange { + var out []FieldChange + keys := map[string]struct{}{} + for k := range actual { + keys[k] = struct{}{} + } + for k := range planned { + keys[k] = struct{}{} + } + for _, k := range slices.Sorted(maps.Keys(keys)) { + kp := path + "." + k + av, aok := actual[k] + pv, pok := planned[k] + switch { + case !aok: + out = append(out, flattenValue(kp, pv, false)...) + case !pok: + out = append(out, flattenValue(kp, av, true)...) + default: + out = append(out, diffValues(kp, av, pv)...) + } + } + return out +} + +func diffValues(path string, actual, planned any) []FieldChange { + am, aok := actual.(map[string]any) + pm, pok := planned.(map[string]any) + if aok && pok { + return diffMaps(path, am, pm) + } + + as, aok := actual.([]any) + ps, pok := planned.([]any) + if aok && pok && len(as) == len(ps) { + var out []FieldChange + for i := range as { + out = append(out, diffValues(fmt.Sprintf("%s[%d]", path, i), as[i], ps[i])...) + } + return out + } + + if equalValues(actual, planned) { + return nil + } + return []FieldChange{{ + Path: path, + Actual: &Value{Value: actual}, + Planned: &Value{Value: planned}, + }} +} + +// equalValues compares two leaf values by their JSON encoding. Both sides +// come from JSON or YAML decoding, but with skewed numeric types: YAML yields +// float64 where the API server returns int64. Encoding normalizes that (7 and +// 7.0 both encode as "7") and json.Marshal sorts map keys, so the comparison +// is deterministic for composite values too. +func equalValues(a, b any) bool { + ab, aerr := json.Marshal(a) + bb, berr := json.Marshal(b) + return aerr == nil && berr == nil && bytes.Equal(ab, bb) +} diff --git a/internal/plan/fallback_test.go b/internal/plan/fallback_test.go new file mode 100644 index 00000000..8382c680 --- /dev/null +++ b/internal/plan/fallback_test.go @@ -0,0 +1,184 @@ +/* +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 plan + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func mr(spec map[string]any) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "ec2.aws.upbound.io/v1beta1", + "kind": "VPC", + "metadata": map[string]any{"name": "my-vpc"}, + "spec": spec, + }} +} + +func TestFallback(t *testing.T) { + type args struct { + desired *unstructured.Unstructured + live *unstructured.Unstructured + } + type want struct { + action Action + changes []FieldChange + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Create": { + reason: "No live counterpart plans as a create listing the desired spec.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{ + "cidrBlock": "10.0.0.0/16", + "region": "us-west-1", + }}), + }, + want: want{ + action: ActionCreate, + changes: []FieldChange{ + {Path: "spec.forProvider.cidrBlock", Planned: &Value{Value: "10.0.0.0/16"}}, + {Path: "spec.forProvider.region", Planned: &Value{Value: "us-west-1"}}, + }, + }, + }, + "NoOp": { + reason: "An identical spec plans as a no-op, even when numeric types differ between YAML and the API server.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{"retention": int64(7)}}), + live: mr(map[string]any{"forProvider": map[string]any{"retention": float64(7)}}), + }, + want: want{action: ActionNoOp}, + }, + "Update": { + reason: "A changed nested field plans as an update with both sides.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}), + live: mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}), + }, + want: want{ + action: ActionUpdate, + changes: []FieldChange{{ + Path: "spec.forProvider.cidrBlock", + Actual: &Value{Value: "10.0.0.0/16"}, + Planned: &Value{Value: "10.1.0.0/16"}, + }}, + }, + }, + "AddAndRemove": { + reason: "Fields only on one side plan as additions and removals.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{"added": true}}), + live: mr(map[string]any{"forProvider": map[string]any{"removed": "gone"}}), + }, + want: want{ + action: ActionUpdate, + changes: []FieldChange{ + {Path: "spec.forProvider.added", Planned: &Value{Value: true}}, + {Path: "spec.forProvider.removed", Actual: &Value{Value: "gone"}}, + }, + }, + }, + "ListElementChange": { + reason: "Same-length lists diff element-wise with indexed paths.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{"zones": []any{"a", "c"}}}), + live: mr(map[string]any{"forProvider": map[string]any{"zones": []any{"a", "b"}}}), + }, + want: want{ + action: ActionUpdate, + changes: []FieldChange{{ + Path: "spec.forProvider.zones[1]", + Actual: &Value{Value: "b"}, + Planned: &Value{Value: "c"}, + }}, + }, + }, + "DeepNestedListOfMaps": { + reason: "Changes inside maps nested in lists diff leaf-wise with indexed paths; untouched siblings stay silent.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{ + "parentIdRef": map[string]any{"name": "nebius-eu-north", "namespace": "modelplane-system"}, + "template": map[string]any{ + "filesystems": []any{map[string]any{ + "attachMode": "READ_ONLY", + "existingFilesystem": map[string]any{"idSelector": map[string]any{"matchControllerRef": true}}, + "mountTag": "modelplane-cache", + }}, + }, + }}), + live: mr(map[string]any{"forProvider": map[string]any{ + "parentIdRef": map[string]any{"name": "nebius-eu-north", "namespace": "modelplane-system"}, + "template": map[string]any{ + "filesystems": []any{map[string]any{ + "attachMode": "READ_WRITE", + "existingFilesystem": map[string]any{"idSelector": map[string]any{"matchControllerRef": true}}, + "mountTag": "modelplane-cache", + }}, + }, + }}), + }, + want: want{ + action: ActionUpdate, + changes: []FieldChange{{ + Path: "spec.forProvider.template.filesystems[0].attachMode", + Actual: &Value{Value: "READ_WRITE"}, + Planned: &Value{Value: "READ_ONLY"}, + }}, + }, + }, + "ListLengthChange": { + reason: "Lists of different lengths diff as one whole-list change.", + args: args{ + desired: mr(map[string]any{"forProvider": map[string]any{"zones": []any{"a"}}}), + live: mr(map[string]any{"forProvider": map[string]any{"zones": []any{"a", "b"}}}), + }, + want: want{ + action: ActionUpdate, + changes: []FieldChange{{ + Path: "spec.forProvider.zones", + Actual: &Value{Value: []any{"a", "b"}}, + Planned: &Value{Value: []any{"a"}}, + }}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := Fallback(tc.args.desired, tc.args.live, "test") + + if !got.Approximate { + t.Errorf("\n%s\nFallback(...): want Approximate, got exact", tc.reason) + } + if got.Action != tc.want.action { + t.Errorf("\n%s\nFallback(...): want action %q, got %q", tc.reason, tc.want.action, got.Action) + } + if diff := cmp.Diff(tc.want.changes, got.Changes, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\nFallback(...): -want changes, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/internal/plan/output.go b/internal/plan/output.go new file mode 100644 index 00000000..756a00ce --- /dev/null +++ b/internal/plan/output.go @@ -0,0 +1,317 @@ +/* +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 plan + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "slices" + "sort" + "strings" + + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// The output formats simulate commands share. A unified diff format +// deliberately does not exist yet: crossplane-diff renders real +// full-manifest unified diffs today, and that renderer is what a future +// -o diff should be once its machinery moves into this repo, rather than a +// lower-fidelity imitation built from the plan layer's field changes. +const ( + FormatPlan = "plan" + FormatJSON = "json" + FormatYAML = "yaml" +) + +// Summary counts plans by what they would do. +type Summary struct { + Create int `json:"create"` + Update int `json:"update"` + Destroy int `json:"destroy"` + Replace int `json:"replace"` + NoOp int `json:"noop"` + Approximate int `json:"approximate"` + Errored int `json:"errored"` +} + +// Summarize counts the given plans. +func Summarize(plans []*ResourcePlan) Summary { + var s Summary + for _, p := range plans { + if p.Error != "" { + s.Errored++ + continue + } + if p.Approximate { + s.Approximate++ + } + switch p.Action { + case ActionCreate: + s.Create++ + case ActionUpdate: + s.Update++ + case ActionDelete: + s.Destroy++ + case ActionReplace: + s.Replace++ + case ActionNoOp: + s.NoOp++ + } + } + return s +} + +// ResultFormatVersion versions the serialized result schema so automation +// consuming it can detect incompatible changes. The schema is alpha; the +// version moves to 1.0 when it stabilizes. +const ResultFormatVersion = "0.1" + +// Result is the serialized form of a simulate run, for -o json|yaml. Errored +// is the primary gate for wrapping automation: when true, at least one +// resource could not be planned and the run's answer is incomplete. +type Result struct { + FormatVersion string `json:"formatVersion"` + Errored bool `json:"errored"` + Summary Summary `json:"summary"` + Plans []*ResourcePlan `json:"plans"` +} + +// NewResult assembles the serializable Result for the given plans. +func NewResult(plans []*ResourcePlan) Result { + s := Summarize(plans) + return Result{ + FormatVersion: ResultFormatVersion, + Errored: s.Errored > 0, + Summary: s, + Plans: plans, + } +} + +// Render writes the plans to w in the given format. +func Render(w io.Writer, format string, plans []*ResourcePlan) error { + switch format { + case FormatPlan: + return renderPlan(w, plans) + case FormatJSON: + out, err := json.MarshalIndent(NewResult(plans), "", " ") + if err != nil { + return errors.Wrap(err, "cannot marshal plans to JSON") + } + _, err = fmt.Fprintln(w, string(out)) + return err + case FormatYAML: + out, err := yaml.Marshal(NewResult(plans)) + if err != nil { + return errors.Wrap(err, "cannot marshal plans to YAML") + } + _, err = fmt.Fprint(w, string(out)) + return err + } + return errors.Errorf("unknown output format %q", format) +} + +func renderPlan(w io.Writer, plans []*ResourcePlan) error { + for _, p := range plans { + _, _ = fmt.Fprintln(w, header(p)) + if p.Error != "" { + _, _ = fmt.Fprintf(w, " cannot plan: %s\n", p.Error) + _, _ = fmt.Fprintln(w) + continue + } + if p.Approximate { + _, _ = fmt.Fprintf(w, " (approximate: %s)\n", p.FallbackReason) + } + renderChangeTree(w, p.Changes) + _, _ = fmt.Fprintln(w) + } + renderSummary(w, plans) + return nil +} + +// header returns the per-resource line, e.g. +// "[~] Key.kms.aws.upbound.io my-key (update)". +func header(p *ResourcePlan) string { + gvk := p.GroupVersionKind() + name := p.Name + if p.Namespace != "" { + name = p.Namespace + "/" + name + } + what := string(p.Action) + if p.Error != "" { + what = "error" + } + return fmt.Sprintf("%s %s.%s %s (%s)", symbol(p), gvk.Kind, gvk.Group, name, what) +} + +func symbol(p *ResourcePlan) string { + if p.Error != "" { + return "[!]" + } + if p.Approximate { + return "[?]" + } + switch p.Action { + case ActionCreate: + return "[+]" + case ActionUpdate: + return "[~]" + case ActionReplace: + return "[-/+]" + case ActionDelete: + return "[-]" + case ActionNoOp: + return "[=]" + } + return "[?]" +} + +// renderChangeTree prints changes as the user's manifest, annotated: nested +// field names as YAML-style keys, changed leaves marked inline. +func renderChangeTree(w io.Writer, changes []FieldChange) { + var prev []string + for _, c := range sortedChanges(changes) { + segs := strings.Split(c.Path, ".") + if len(segs) == 0 { + continue + } + parents, leaf := segs[:len(segs)-1], segs[len(segs)-1] + + // Print the parent keys that differ from the previous change's path. + common := 0 + for common < len(parents) && common < len(prev) && parents[common] == prev[common] { + common++ + } + for i := common; i < len(parents); i++ { + _, _ = fmt.Fprintf(w, " %s%s:\n", strings.Repeat(" ", i), parents[i]) + } + prev = parents + + indent := " " + strings.Repeat(" ", len(parents)) + note := "" + if c.RequiresReplace { + note = " # forces replacement" + } + switch { + case c.Actual != nil && c.Planned != nil: + _, _ = fmt.Fprintf(w, "%s~ %s: %s -> %s%s\n", indent, leaf, formatValue(c.Actual), formatValue(c.Planned), note) + case c.Planned != nil: + _, _ = fmt.Fprintf(w, "%s+ %s: %s%s\n", indent, leaf, formatValue(c.Planned), note) + case c.Actual != nil: + _, _ = fmt.Fprintf(w, "%s- %s: %s%s\n", indent, leaf, formatValue(c.Actual), note) + } + } +} + +func renderSummary(w io.Writer, plans []*ResourcePlan) { + s := Summarize(plans) + + line := fmt.Sprintf("Plan: %d to add, %d to change, %d to destroy", s.Create, s.Update, s.Destroy) + if s.Replace > 0 { + verb := "requires" + if s.Replace > 1 { + verb = "require" + } + line += fmt.Sprintf(", %d %s replacement", s.Replace, verb) + } + _, _ = fmt.Fprintln(w, line+".") + + if s.Replace > 0 { + fields := replaceFields(plans) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintf(w, "Warning: %s force replacement: %s.\n", pluralize(s.Replace, "resource has fields that", "resources have fields that"), strings.Join(fields, ", ")) + _, _ = fmt.Fprintln(w, "Crossplane providers never destroy and recreate to apply a change.") + _, _ = fmt.Fprintln(w, "Applying this leaves the resource unsynced until it is replaced manually.") + } + + if s.Approximate > 0 { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintf(w, "Warning: %s approximate, computed client-side without a provider diff\n", pluralize(s.Approximate, "result is", "results are")) + _, _ = fmt.Fprintln(w, "server: no replacement detection, no computed values.") + } + + if s.Errored > 0 { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintf(w, "Warning: %s could not be planned.\n", pluralize(s.Errored, "resource", "resources")) + } +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, singular) + } + return fmt.Sprintf("%d %s", n, plural) +} + +// replaceFields returns the deduplicated, sorted field names (last path +// segments) that force replacement across all plans. +func replaceFields(plans []*ResourcePlan) []string { + set := map[string]struct{}{} + for _, p := range plans { + for _, f := range lastSegments(p.ReplacePaths) { + set[f] = struct{}{} + } + } + return slices.Sorted(maps.Keys(set)) +} + +func lastSegments(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, p := range paths { + segs := strings.Split(p, ".") + out = append(out, segs[len(segs)-1]) + } + return out +} + +func sortedChanges(changes []FieldChange) []FieldChange { + out := make([]FieldChange, len(changes)) + copy(out, changes) + sort.SliceStable(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// formatValue renders one side of a change for humans. +func formatValue(v *Value) string { + switch { + case v == nil: + return "(none)" + case v.Sensitive: + return "(sensitive value)" + case v.Unknown: + return "(known after apply)" + case v.Unresolved: + return "(unresolved reference)" + } + switch t := v.Value.(type) { + case nil: + return "null" + case string: + return fmt.Sprintf("%q", t) + case map[string]any, []any: + out, err := json.Marshal(t) + if err != nil { + return fmt.Sprintf("%v", t) + } + return string(out) + default: + return fmt.Sprintf("%v", t) + } +} diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go new file mode 100644 index 00000000..a5339bcb --- /dev/null +++ b/internal/plan/output_test.go @@ -0,0 +1,152 @@ +/* +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 plan + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func testPlans() []*ResourcePlan { + return []*ResourcePlan{ + { + APIVersion: "ec2.aws.upbound.io/v1beta1", Kind: "VPC", Name: "my-vpc", + Action: ActionCreate, + Changes: []FieldChange{ + {Path: "spec.forProvider.cidrBlock", Planned: &Value{Value: "10.0.0.0/16"}}, + {Path: "spec.forProvider.region", Planned: &Value{Value: "us-west-1"}}, + }, + }, + { + APIVersion: "rds.aws.upbound.io/v1beta1", Kind: "Instance", Name: "prod-db", + Action: ActionReplace, + Changes: []FieldChange{{ + Path: "spec.forProvider.engineVersion", + Actual: &Value{Value: "14.7"}, + Planned: &Value{Value: "15.4"}, + RequiresReplace: true, + }}, + ReplacePaths: []string{"spec.forProvider.engineVersion"}, + }, + { + APIVersion: "kms.aws.upbound.io/v1beta1", Kind: "Key", Name: "my-key", + Action: ActionNoOp, + }, + { + APIVersion: "s3.aws.upbound.io/v1beta2", Kind: "Bucket", Name: "my-bucket", + Action: ActionUpdate, + Approximate: true, FallbackReason: "no diff server for Bucket.s3.aws.upbound.io", + Changes: []FieldChange{{ + Path: "spec.forProvider.forceDestroy", + Actual: &Value{Value: false}, + Planned: &Value{Value: true}, + }}, + }, + { + APIVersion: "ec2.aws.upbound.io/v1beta1", Kind: "Subnet", Name: "my-subnet", + Error: "rpc error: code = Internal desc = boom", + }, + } +} + +func TestRenderPlan(t *testing.T) { + want := `[+] VPC.ec2.aws.upbound.io my-vpc (create) + spec: + forProvider: + + cidrBlock: "10.0.0.0/16" + + region: "us-west-1" + +[-/+] Instance.rds.aws.upbound.io prod-db (replace) + spec: + forProvider: + ~ engineVersion: "14.7" -> "15.4" # forces replacement + +[=] Key.kms.aws.upbound.io my-key (no-op) + +[?] Bucket.s3.aws.upbound.io my-bucket (update) + (approximate: no diff server for Bucket.s3.aws.upbound.io) + spec: + forProvider: + ~ forceDestroy: false -> true + +[!] Subnet.ec2.aws.upbound.io my-subnet (error) + cannot plan: rpc error: code = Internal desc = boom + +Plan: 1 to add, 1 to change, 0 to destroy, 1 requires replacement. + +Warning: 1 resource has fields that force replacement: engineVersion. +Crossplane providers never destroy and recreate to apply a change. +Applying this leaves the resource unsynced until it is replaced manually. + +Warning: 1 result is approximate, computed client-side without a provider diff +server: no replacement detection, no computed values. + +Warning: 1 resource could not be planned. +` + + var buf bytes.Buffer + if err := Render(&buf, FormatPlan, testPlans()); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("Render(plan): -want, +got:\n%s", diff) + } +} + +func TestRenderUnknownFormat(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, "diff", testPlans()); err == nil { + t.Errorf("Render(diff): want an error for an unsupported format") + } +} + +func TestRenderJSON(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, testPlans()); err != nil { + t.Fatalf("Render(json): unexpected error: %v", err) + } + + got := Result{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("Render(json): output is not valid JSON: %v", err) + } + + want := Summary{Create: 1, Update: 1, Replace: 1, NoOp: 1, Approximate: 1, Errored: 1} + if diff := cmp.Diff(want, got.Summary); diff != "" { + t.Errorf("Render(json): -want summary, +got:\n%s", diff) + } + if len(got.Plans) != 5 { + t.Errorf("Render(json): want 5 plans, got %d", len(got.Plans)) + } + if got.FormatVersion != ResultFormatVersion { + t.Errorf("Render(json): want formatVersion %q, got %q", ResultFormatVersion, got.FormatVersion) + } + if !got.Errored { + t.Errorf("Render(json): want errored true with a failed plan in the set") + } +} + +func TestSummarize(t *testing.T) { + got := Summarize(testPlans()) + want := Summary{Create: 1, Update: 1, Replace: 1, NoOp: 1, Approximate: 1, Errored: 1} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Summarize(...): -want, +got:\n%s", diff) + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go new file mode 100644 index 00000000..bff4dce6 --- /dev/null +++ b/internal/plan/plan.go @@ -0,0 +1,64 @@ +/* +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 plan + +import ( + "context" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/protobuf/types/known/structpb" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// Plan asks a provider diff server what applying desired would do. actual is +// the live resource the desired one corresponds to, carrying its +// status.atProvider and external-name annotation; nil means the resource does +// not exist yet and plans as a create. store seeds the server's in-memory +// Kubernetes client with objects (Secrets, referenced resources) it may need +// to resolve the desired state. +// +// gRPC errors are returned as-is so callers can branch on them; use +// IsNotSupported to detect the "this resource cannot be planned" case that +// should fall back to an approximate client-side diff. +func Plan(ctx context.Context, c diffv1alpha1.PlanServiceClient, desired, actual *unstructured.Unstructured, store []*unstructured.Unstructured) (*ResourcePlan, error) { + req := &diffv1alpha1.PlanRequest{} + + var err error + if req.DesiredResource, err = structpb.NewStruct(desired.Object); err != nil { + return nil, errors.Wrap(err, "cannot convert desired resource") + } + if actual != nil { + if req.ActualResource, err = structpb.NewStruct(actual.Object); err != nil { + return nil, errors.Wrap(err, "cannot convert actual resource") + } + } + for _, o := range store { + s, err := structpb.NewStruct(o.Object) + if err != nil { + return nil, errors.Wrapf(err, "cannot convert object store entry %s/%s", o.GetNamespace(), o.GetName()) + } + req.KubernetesObjectStore = append(req.KubernetesObjectStore, s) + } + + rsp, err := c.Plan(ctx, req) + if err != nil { + return nil, err + } + return FromResponse(desired, rsp) +} diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go new file mode 100644 index 00000000..56355b13 --- /dev/null +++ b/internal/plan/plan_test.go @@ -0,0 +1,97 @@ +/* +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 plan + +import ( + "context" + "testing" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// fakePlanClient implements diffv1alpha1.PlanServiceClient in-process, +// capturing the request and returning a canned response or error. +type fakePlanClient struct { + rsp *diffv1alpha1.PlanResponse + err error + got *diffv1alpha1.PlanRequest +} + +func (f *fakePlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + f.got = in + return f.rsp, f.err +} + +func TestPlan(t *testing.T) { + desired := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) + actual := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) + secret := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "creds", "namespace": "default"}, + }} + + t.Run("RequestCarriesAllState", func(t *testing.T) { + c := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE}} + + got, err := Plan(context.Background(), c, desired, actual, []*unstructured.Unstructured{secret}) + if err != nil { + t.Fatalf("Plan(...): unexpected error: %v", err) + } + if got.Action != ActionUpdate { + t.Errorf("Plan(...): want action %q, got %q", ActionUpdate, got.Action) + } + + if c.got.GetDesiredResource().GetFields()["kind"].GetStringValue() != "VPC" { + t.Errorf("Plan(...): desired resource not carried in request") + } + if c.got.GetActualResource() == nil { + t.Errorf("Plan(...): actual resource not carried in request") + } + if len(c.got.GetKubernetesObjectStore()) != 1 { + t.Errorf("Plan(...): want 1 object store entry, got %d", len(c.got.GetKubernetesObjectStore())) + } + }) + + t.Run("NoActualMeansCreate", func(t *testing.T) { + c := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} + + got, err := Plan(context.Background(), c, desired, nil, nil) + if err != nil { + t.Fatalf("Plan(...): unexpected error: %v", err) + } + if got.Action != ActionCreate { + t.Errorf("Plan(...): want action %q, got %q", ActionCreate, got.Action) + } + if c.got.GetActualResource() != nil { + t.Errorf("Plan(...): actual resource should be unset for a create") + } + }) + + t.Run("ServerErrorsPassThrough", func(t *testing.T) { + c := &fakePlanClient{err: status.Error(codes.NotFound, "not served here")} + + _, err := Plan(context.Background(), c, desired, nil, nil) + if status.Code(err) != codes.NotFound { + t.Errorf("Plan(...): want NotFound to pass through, got %v", err) + } + }) +} diff --git a/internal/plan/proto.go b/internal/plan/proto.go new file mode 100644 index 00000000..008343ea --- /dev/null +++ b/internal/plan/proto.go @@ -0,0 +1,126 @@ +/* +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 plan + +import ( + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// violationDiffComputationNotSupported is the PreconditionFailure violation +// type a diff server attaches to a FAILED_PRECONDITION status when it serves +// a resource's API group but cannot compute a diff for it. It is defined in +// upjet's pkg/diffserver/internal/plan but not exported. +const violationDiffComputationNotSupported = "DIFF_COMPUTATION_NOT_SUPPORTED" + +// FromResponse converts a diff server's PlanResponse into the ResourcePlan +// for the given desired resource. +func FromResponse(desired *unstructured.Unstructured, rsp *diffv1alpha1.PlanResponse) (*ResourcePlan, error) { + p := NewResourcePlan(desired) + + switch rsp.GetAction() { + case diffv1alpha1.Action_ACTION_NO_OP: + p.Action = ActionNoOp + case diffv1alpha1.Action_ACTION_CREATE: + p.Action = ActionCreate + case diffv1alpha1.Action_ACTION_UPDATE: + p.Action = ActionUpdate + case diffv1alpha1.Action_ACTION_REPLACE: + p.Action = ActionReplace + case diffv1alpha1.Action_ACTION_UNSPECIFIED: + return nil, errors.New("diff server returned an unspecified action") + default: + return nil, errors.Errorf("diff server returned an unknown action %q", rsp.GetAction()) + } + + for _, c := range rsp.GetChanges() { + fc := FieldChange{ + Path: c.GetField(), + Actual: fromFieldValue(c.GetActual()), + Planned: fromFieldValue(c.GetPlanned()), + RequiresReplace: c.GetRequiresReplace(), + Origin: fromOrigin(c.GetOrigin()), + } + p.Changes = append(p.Changes, fc) + if fc.RequiresReplace { + p.ReplacePaths = append(p.ReplacePaths, fc.Path) + } + } + + return p, nil +} + +func fromFieldValue(fv *diffv1alpha1.FieldValue) *Value { + if fv == nil { + return nil + } + switch k := fv.GetKind().(type) { + case *diffv1alpha1.FieldValue_Value: + return &Value{Value: k.Value.AsInterface()} + case *diffv1alpha1.FieldValue_Absence: + switch k.Absence { + case diffv1alpha1.Absence_ABSENCE_UNKNOWN: + return &Value{Unknown: true} + case diffv1alpha1.Absence_ABSENCE_SENSITIVE: + return &Value{Sensitive: true} + case diffv1alpha1.Absence_ABSENCE_UNRESOLVED: + return &Value{Unresolved: true} + case diffv1alpha1.Absence_ABSENCE_UNSPECIFIED: + return nil + } + } + return nil +} + +func fromOrigin(o diffv1alpha1.Origin) Origin { + switch o { + case diffv1alpha1.Origin_ORIGIN_DESIRED_STATE: + return OriginDesired + case diffv1alpha1.Origin_ORIGIN_PROVIDER: + return OriginProvider + case diffv1alpha1.Origin_ORIGIN_UNSPECIFIED: + return "" + } + return "" +} + +// IsNotSupported reports whether err is a diff server saying it serves this +// resource's API group but cannot compute a diff for it. Callers fall back to +// the approximate client-side diff for such resources. +func IsNotSupported(err error) bool { + s, ok := status.FromError(err) + if !ok || s.Code() != codes.FailedPrecondition { + return false + } + for _, d := range s.Details() { + pf, ok := d.(*errdetails.PreconditionFailure) + if !ok { + continue + } + for _, v := range pf.GetViolations() { + if v.GetType() == violationDiffComputationNotSupported { + return true + } + } + } + return false +} diff --git a/internal/plan/proto_test.go b/internal/plan/proto_test.go new file mode 100644 index 00000000..f05d1fbb --- /dev/null +++ b/internal/plan/proto_test.go @@ -0,0 +1,233 @@ +/* +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 plan + +import ( + "testing" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +func testDesired() *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "ec2.aws.upbound.io/v1beta1", + "kind": "VPC", + "metadata": map[string]any{"name": "my-vpc"}, + }} +} + +func value(v any) *diffv1alpha1.FieldValue { + pv, err := structpb.NewValue(v) + if err != nil { + panic(err) + } + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: pv}} +} + +func absence(a diffv1alpha1.Absence) *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Absence{Absence: a}} +} + +func TestFromResponse(t *testing.T) { + type args struct { + rsp *diffv1alpha1.PlanResponse + } + type want struct { + plan *ResourcePlan + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Update": { + reason: "An update with a concrete value change should decode action, values, and origin.", + args: args{rsp: &diffv1alpha1.PlanResponse{ + Action: diffv1alpha1.Action_ACTION_UPDATE, + Changes: []*diffv1alpha1.FieldChange{{ + Field: "spec.forProvider.cidrBlock", + Actual: value("10.0.0.0/16"), + Planned: value("10.1.0.0/16"), + Origin: diffv1alpha1.Origin_ORIGIN_DESIRED_STATE, + }}, + }}, + want: want{plan: &ResourcePlan{ + APIVersion: "ec2.aws.upbound.io/v1beta1", + Kind: "VPC", + Name: "my-vpc", + Action: ActionUpdate, + Changes: []FieldChange{{ + Path: "spec.forProvider.cidrBlock", + Actual: &Value{Value: "10.0.0.0/16"}, + Planned: &Value{Value: "10.1.0.0/16"}, + Origin: OriginDesired, + }}, + }}, + }, + "Replace": { + reason: "A change that requires replacement should be recorded in the change and in ReplacePaths.", + args: args{rsp: &diffv1alpha1.PlanResponse{ + Action: diffv1alpha1.Action_ACTION_REPLACE, + Changes: []*diffv1alpha1.FieldChange{{ + Field: "spec.forProvider.engineVersion", + Actual: value("14.7"), + Planned: value("15.4"), + RequiresReplace: true, + }}, + }}, + want: want{plan: &ResourcePlan{ + APIVersion: "ec2.aws.upbound.io/v1beta1", + Kind: "VPC", + Name: "my-vpc", + Action: ActionReplace, + Changes: []FieldChange{{ + Path: "spec.forProvider.engineVersion", + Actual: &Value{Value: "14.7"}, + Planned: &Value{Value: "15.4"}, + RequiresReplace: true, + }}, + ReplacePaths: []string{"spec.forProvider.engineVersion"}, + }}, + }, + "Absences": { + reason: "Sensitive, unknown, and unresolved values should decode to their markers.", + args: args{rsp: &diffv1alpha1.PlanResponse{ + Action: diffv1alpha1.Action_ACTION_CREATE, + Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.password", Planned: absence(diffv1alpha1.Absence_ABSENCE_SENSITIVE)}, + {Field: "status.atProvider.id", Planned: absence(diffv1alpha1.Absence_ABSENCE_UNKNOWN), Origin: diffv1alpha1.Origin_ORIGIN_PROVIDER}, + {Field: "spec.forProvider.vpcId", Planned: absence(diffv1alpha1.Absence_ABSENCE_UNRESOLVED)}, + }, + }}, + want: want{plan: &ResourcePlan{ + APIVersion: "ec2.aws.upbound.io/v1beta1", + Kind: "VPC", + Name: "my-vpc", + Action: ActionCreate, + Changes: []FieldChange{ + {Path: "spec.forProvider.password", Planned: &Value{Sensitive: true}}, + {Path: "status.atProvider.id", Planned: &Value{Unknown: true}, Origin: OriginProvider}, + {Path: "spec.forProvider.vpcId", Planned: &Value{Unresolved: true}}, + }, + }}, + }, + "NoOp": { + reason: "A no-op should decode with no changes.", + args: args{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_NO_OP}}, + want: want{plan: &ResourcePlan{ + APIVersion: "ec2.aws.upbound.io/v1beta1", + Kind: "VPC", + Name: "my-vpc", + Action: ActionNoOp, + }}, + }, + "UnspecifiedAction": { + reason: "An unspecified action is a server bug and should error rather than guess.", + args: args{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UNSPECIFIED}}, + want: want{err: errors.New("diff server returned an unspecified action")}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := FromResponse(testDesired(), tc.args.rsp) + + if tc.want.err != nil { + if err == nil || err.Error() != tc.want.err.Error() { + t.Errorf("\n%s\nFromResponse(...): want error %q, got %v", tc.reason, tc.want.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nFromResponse(...): unexpected error: %v", tc.reason, err) + } + if diff := cmp.Diff(tc.want.plan, got, cmpopts.IgnoreFields(ResourcePlan{}, "Desired")); diff != "" { + t.Errorf("\n%s\nFromResponse(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestIsNotSupported(t *testing.T) { + notSupported, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{{ + Type: violationDiffComputationNotSupported, + Subject: "ec2.aws.upbound.io/v1beta1, Kind=VPC", + }}, + }) + if err != nil { + t.Fatalf("cannot build status: %v", err) + } + + otherPrecondition, err := status.New(codes.FailedPrecondition, "something else").WithDetails(&errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{{Type: "SOMETHING_ELSE"}}, + }) + if err != nil { + t.Fatalf("cannot build status: %v", err) + } + + cases := map[string]struct { + reason string + err error + want bool + }{ + "NotSupported": { + reason: "FAILED_PRECONDITION with the DIFF_COMPUTATION_NOT_SUPPORTED violation is the fallback signal.", + err: notSupported.Err(), + want: true, + }, + "OtherViolation": { + reason: "FAILED_PRECONDITION with another violation type is not the fallback signal.", + err: otherPrecondition.Err(), + want: false, + }, + "PlainPrecondition": { + reason: "FAILED_PRECONDITION without details is not the fallback signal.", + err: status.Error(codes.FailedPrecondition, "nope"), + want: false, + }, + "OtherCode": { + reason: "Other gRPC codes are real failures.", + err: status.Error(codes.Internal, "boom"), + want: false, + }, + "NotAStatus": { + reason: "A non-gRPC error is a real failure.", + err: errors.New("boom"), + want: false, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := IsNotSupported(tc.err); got != tc.want { + t.Errorf("\n%s\nIsNotSupported(...): want %t, got %t", tc.reason, tc.want, got) + } + }) + } +} diff --git a/internal/plan/types.go b/internal/plan/types.go new file mode 100644 index 00000000..29ab7fdd --- /dev/null +++ b/internal/plan/types.go @@ -0,0 +1,125 @@ +/* +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 plan contains the shared per-resource plan model for simulate +// commands: the typed result, the client for provider diff servers, the +// client-side approximate fallback, and the output renderers. +package plan + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Action is what applying a resource would do. +type Action string + +// The actions a plan can report. Replace is never something a Crossplane +// provider does to apply a change; it reports that the change could only be +// applied by destroying and recreating the external resource. Delete is +// reserved for composition-driven removals (project simulate); a diff server +// never returns it. +const ( + ActionNoOp Action = "no-op" + ActionCreate Action = "create" + ActionUpdate Action = "update" + ActionReplace Action = "replace" + ActionDelete Action = "delete" +) + +// Origin says where a planned change comes from. +type Origin string + +// A change originates either from the desired state the user wrote, or from +// the provider (a default, or a late-initialized value). +const ( + OriginDesired Origin = "desired" + OriginProvider Origin = "provider" +) + +// Value is one side of a field change. Exactly one of Value, Unknown, +// Sensitive, or Unresolved describes it. A nil *Value means the field does +// not exist on that side at all. +type Value struct { + // Value is the concrete value, when it is known and printable. + Value any `json:"value,omitempty"` + // Unknown means the value is known only after apply (computed). + Unknown bool `json:"unknown,omitempty"` + // Sensitive means the value exists but is redacted. + Sensitive bool `json:"sensitive,omitempty"` + // Unresolved means the value comes from a reference or Secret the diff + // server could not resolve. + Unresolved bool `json:"unresolved,omitempty"` +} + +// FieldChange is one changed field in a plan, in CRD terms. +type FieldChange struct { + // Path is the CRD field path, e.g. spec.forProvider.engineVersion. + Path string `json:"path"` + // Actual is the field's current value; nil when the field does not exist + // yet. + Actual *Value `json:"actual,omitempty"` + // Planned is the field's value after apply; nil when the change removes + // the field. + Planned *Value `json:"planned,omitempty"` + // RequiresReplace means this change can only be applied by destroying and + // recreating the external resource. + RequiresReplace bool `json:"requiresReplace,omitempty"` + // Origin says whether the change comes from the desired state or from the + // provider. Empty when unknown (e.g. the approximate fallback). + Origin Origin `json:"origin,omitempty"` +} + +// ResourcePlan is the plan for a single resource: what applying it would do. +type ResourcePlan struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Action Action `json:"action,omitempty"` + Changes []FieldChange `json:"changes,omitempty"` + // ReplacePaths lists the field paths that force replacement. + ReplacePaths []string `json:"replacePaths,omitempty"` + // Approximate means this plan was computed client-side, without a provider + // diff server: no replacement detection, no computed values. + Approximate bool `json:"approximate,omitempty"` + // FallbackReason says why this plan is approximate. + FallbackReason string `json:"fallbackReason,omitempty"` + // Error is set when no plan could be computed for this resource at all. + Error string `json:"error,omitempty"` + + // Desired is the resource the plan is for. It is carried for rendering + // (the plan output shows the user's own manifest, annotated) and is not + // part of the serialized result. + Desired *unstructured.Unstructured `json:"-"` +} + +// GroupVersionKind returns the plan's resource type. +func (p *ResourcePlan) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(p.APIVersion, p.Kind) +} + +// NewResourcePlan returns a ResourcePlan identifying the given desired +// resource, with no action decided yet. +func NewResourcePlan(desired *unstructured.Unstructured) *ResourcePlan { + return &ResourcePlan{ + APIVersion: desired.GetAPIVersion(), + Kind: desired.GetKind(), + Name: desired.GetName(), + Namespace: desired.GetNamespace(), + Desired: desired, + } +} diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go index 49ed5ccd..6125310c 100644 --- a/internal/terminal/spinner.go +++ b/internal/terminal/spinner.go @@ -30,10 +30,39 @@ import ( bspinner "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" "github.com/crossplane/cli/v2/internal/async" ) +// ttyInput returns the input to attach to a spinner program. The spinners +// never use key input, but bubbletea queries the terminal for capabilities +// (synchronized output and grapheme clustering) on startup, and the terminal +// writes its replies to stdin: without a reader in raw mode they echo as +// stray escape sequences after the spinner text. Attaching a TTY stdin lets +// bubbletea consume them. Piped stdin stays detached so spinners never eat +// piped data. +func ttyInput() io.Reader { + if term.IsTerminal(os.Stdin.Fd()) { + return os.Stdin + } + return nil +} + +// interruptKey handles ctrl+c for a spinner program with a TTY attached: raw +// mode swallows SIGINT, so the key arrives as input and must mirror what the +// signal handler does for external signals. +func interruptKey(p *tea.Program, msg tea.Msg) { + k, ok := msg.(tea.KeyPressMsg) + if !ok || k.String() != "ctrl+c" { + return + } + if p != nil { + _ = p.ReleaseTerminal() + } + os.Exit(130) +} + // SpinnerPrinter prints spinners to the console. type SpinnerPrinter interface { // NewSuccessSpinner returns a new success spinner. @@ -209,6 +238,8 @@ func (m *MultiSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mu.Lock() defer m.mu.Unlock() + interruptKey(m.program, msg) + if _, ok := msg.(tickMsg); !ok { return m, nil } @@ -278,7 +309,7 @@ func (m *MultiSpinner) Fail(title string) { // Start starts the spinners. func (m *MultiSpinner) Start() { m.program = tea.NewProgram(m, - tea.WithInput(nil), + tea.WithInput(ttyInput()), tea.WithoutSignalHandler(), tea.WithOutput(m.out), ) @@ -358,6 +389,8 @@ func (ss *SuccessSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ss.mu.Lock() defer ss.mu.Unlock() + interruptKey(ss.program, msg) + if _, ok := msg.(tickMsg); !ok { return ss, nil } @@ -425,7 +458,7 @@ func (ss *SuccessSpinner) Logf(format string, args ...any) { func (ss *SuccessSpinner) Start() { ss.program = tea.NewProgram(ss, tea.WithOutput(ss.out), - tea.WithInput(nil), + tea.WithInput(ttyInput()), tea.WithoutSignalHandler(), ) diff --git a/internal/xpkg/metadata.go b/internal/xpkg/metadata.go index 91d722ce..44ae2829 100644 --- a/internal/xpkg/metadata.go +++ b/internal/xpkg/metadata.go @@ -23,6 +23,7 @@ import ( apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -121,3 +122,19 @@ func convertXRDv2ToV1(xrd *xrdv2.CompositeResourceDefinition) (*xrdv1.CompositeR func crdFilename(plural, group string) string { return fmt.Sprintf("%s.%s.yaml", plural, group) } + +// GroupKinds returns the group and kind of every CustomResourceDefinition in +// the package. XRDs and other objects are skipped: for routing resources to a +// provider's diff server only the provider's own CRDs matter. +func GroupKinds(pkg *parser.Package) []schema.GroupKind { + var gks []schema.GroupKind + for _, obj := range pkg.GetObjects() { + switch c := obj.(type) { + case *apiextv1.CustomResourceDefinition: + gks = append(gks, schema.GroupKind{Group: c.Spec.Group, Kind: c.Spec.Names.Kind}) + case *apiextv1beta1.CustomResourceDefinition: + gks = append(gks, schema.GroupKind{Group: c.Spec.Group, Kind: c.Spec.Names.Kind}) + } + } + return gks +} diff --git a/nix/vendor-hashes.nix b/nix/vendor-hashes.nix index 65849b3e..2e92b6f5 100644 --- a/nix/vendor-hashes.nix +++ b/nix/vendor-hashes.nix @@ -12,5 +12,5 @@ # hand.) { # Root module: github.com/crossplane/cli/v2 - root = "sha256-6mWzuyq9PqJniswsNdi5LJl4QLe7XcyWM+s+UHcDkiE="; + root = "sha256-MO9qTpEGofkSmktxC+ubi9m46TvDh570taHwKIQHTGA="; }