From d2a9ddff6ecb09edb08296fbd9b38398898b0020 Mon Sep 17 00:00:00 2001 From: Raffael Sahli Date: Mon, 21 Sep 2026 12:14:52 +0200 Subject: [PATCH 1/2] feat: add resource {reconcile,suspend,resume} commands Signed-off-by: Raffael Sahli --- cmd/crossplane/resource/resource.go | 7 +- cmd/crossplane/trace/help/reconcile.md | 120 ++++++++++++++++ cmd/crossplane/trace/help/resume.md | 120 ++++++++++++++++ cmd/crossplane/trace/help/suspend.md | 120 ++++++++++++++++ cmd/crossplane/trace/reconcile.go | 188 +++++++++++++++++++++++++ cmd/crossplane/trace/resume.go | 186 ++++++++++++++++++++++++ cmd/crossplane/trace/suspend.go | 185 ++++++++++++++++++++++++ 7 files changed, 924 insertions(+), 2 deletions(-) create mode 100644 cmd/crossplane/trace/help/reconcile.md create mode 100644 cmd/crossplane/trace/help/resume.md create mode 100644 cmd/crossplane/trace/help/suspend.md create mode 100644 cmd/crossplane/trace/reconcile.go create mode 100644 cmd/crossplane/trace/resume.go create mode 100644 cmd/crossplane/trace/suspend.go diff --git a/cmd/crossplane/resource/resource.go b/cmd/crossplane/resource/resource.go index c930152e..e460ba47 100644 --- a/cmd/crossplane/resource/resource.go +++ b/cmd/crossplane/resource/resource.go @@ -24,6 +24,9 @@ import ( // Cmd contains commands for working with Crossplane resources. type Cmd struct { - Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource for troubleshooting."` - Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` + Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource for troubleshooting."` + Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` + Suspend trace.SuspendCmd `cmd:"" help:"Suspend crossplane resource reconciliation."` + Resume trace.ResumeCmd `cmd:"" help:"Resume reconciliation for suspended crossplane resources."` + Reconcile trace.ReconcileCmd `cmd:"" help:"Trigger an immediate reconcilication for a crossplane resource."` } diff --git a/cmd/crossplane/trace/help/reconcile.md b/cmd/crossplane/trace/help/reconcile.md new file mode 100644 index 00000000..9ec84d8c --- /dev/null +++ b/cmd/crossplane/trace/help/reconcile.md @@ -0,0 +1,120 @@ +The `resource reconcile` command triggers an immediate reconciliation of a Crossplane resource (Claim, Composite, or +Managed Resource). + +The command requires a resource type and a resource name: + +```shell +crossplane resource reconcile +``` + +Kubernetes-style `/` input works too: for example, `crossplane +resource reconcile example.crossplane.io/my-xr`. + +You can further specify the kind as `TYPE[.VERSION][.GROUP]` if needed; for +example, `mykind.example.org` or `mykind.v1alpha1.example.org`. + +By default, `crossplane resource reconcile` uses the Kubernetes configuration at +`~/.kube/config`. Override with the `KUBECONFIG` environment variable. + +By default the trigger only applies to the requested resource. Using `--cascade` this can be propagated +to all sub resources recursively and can also be combined with `--watch` to follow the status. + +```shell +crossplane resource reconcile --cascade --watch +``` + +## Output options + +By default, `reconcile` prints to the terminal as a tree, truncating the `Ready` and +`Status` messages to 64 characters. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +### Wide output + +Use `--output=wide` to print the full `Ready` and `Status` messages even when +they exceed 64 characters, and other kind-specific printer columns. + +### Graphviz dot output + +Use `--output=dot` to print a textual +[Graphviz dot](https://graphviz.org/docs/layouts/dot/) graph. Pipe to `dot` to +render an image: + +```shell +crossplane resource reconcile cluster.aws.platformref.upbound.io platform-ref-aws -o dot | dot -Tpng -o graph.png +``` + +## Print connection secrets + +Use `--show-connection-secrets` to include connection-secret names alongside the +other resources. Secret values are never printed. Output includes the secret +name and namespace. + +## Print package dependencies + +The `--show-package-dependencies` flag controls how the display of package +dependencies: + +- `unique` (default): include each required package only once. +- `all`: show every package that requires the same dependency. +- `none`: hide all package dependencies. + +## Print package revisions + +The `--show-package-revisions` flag controls the display of package revisions: + +- `active` (default): show only the active revisions. +- `all`: show all revisions, including inactive ones. +- `none`: hide all revisions. + +## Examples + +Reconcile a `MyKind` resource named `my-res` in the namespace `my-ns`: + +```shell +crossplane resource reconcile mykind my-res -n my-ns +``` + +Reconcile all `MyKind` resources in the namespace `my-ns`: + +```shell +crossplane resource reconcile mykind -n my-ns +``` + +Wide format with full errors, condition messages, and kind-specific columns: + +```shell +crossplane resource reconcile mykind my-res -n my-ns -o wide +``` + +Show connection secret names alongside the resources: + +```shell +crossplane resource reconcile mykind my-res -n my-ns --show-connection-secrets +``` + +Output a Graphviz dot graph and pipe to dot to generate a PNG: + +```shell +crossplane resource reconcile mykind my-res -n my-ns -o dot | dot -Tpng -o output.png +``` + +Output all retrieved resources as JSON and pipe to jq for color: + +```shell +crossplane resource reconcile mykind my-res -n my-ns -o json | jq +``` + +Output debug logs to stderr while piping a dot graph to dot: + +```shell +crossplane resource reconcile mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png +``` + +Watch a resource continuously until its deletion: + +```shell +crossplane resource reconcile mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/trace/help/resume.md b/cmd/crossplane/trace/help/resume.md new file mode 100644 index 00000000..501354d2 --- /dev/null +++ b/cmd/crossplane/trace/help/resume.md @@ -0,0 +1,120 @@ +The `resource resume` resumes any paused Crossplane resources (Claim, Composite, or +Managed Resource). + +The command requires a resource type and a resource name: + +```shell +crossplane resource resume +``` + +Kubernetes-style `/` input works too: for example, `crossplane +resource resume example.crossplane.io/my-xr`. + +You can further specify the kind as `TYPE[.VERSION][.GROUP]` if needed; for +example, `mykind.example.org` or `mykind.v1alpha1.example.org`. + +By default, `crossplane resource resume` uses the Kubernetes configuration at +`~/.kube/config`. Override with the `KUBECONFIG` environment variable. + +By default the trigger only applies to the requested resource. Using `--cascade` this can be propagated +to all sub resources recursively and can also be combined with `--watch` to follow the status. + +```shell +crossplane resource reconcile --cascade --watch +``` + +## Output options + +By default, `resume` prints to the terminal as a tree, truncating the `Ready` and +`Status` messages to 64 characters. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +### Wide output + +Use `--output=wide` to print the full `Ready` and `Status` messages even when +they exceed 64 characters, and other kind-specific printer columns. + +### Graphviz dot output + +Use `--output=dot` to print a textual +[Graphviz dot](https://graphviz.org/docs/layouts/dot/) graph. Pipe to `dot` to +render an image: + +```shell +crossplane resource resume cluster.aws.platformref.upbound.io platform-ref-aws -o dot | dot -Tpng -o graph.png +``` + +## Print connection secrets + +Use `--show-connection-secrets` to include connection-secret names alongside the +other resources. Secret values are never printed. Output includes the secret +name and namespace. + +## Print package dependencies + +The `--show-package-dependencies` flag controls how the display of package +dependencies: + +- `unique` (default): include each required package only once. +- `all`: show every package that requires the same dependency. +- `none`: hide all package dependencies. + +## Print package revisions + +The `--show-package-revisions` flag controls the display of package revisions: + +- `active` (default): show only the active revisions. +- `all`: show all revisions, including inactive ones. +- `none`: hide all revisions. + +## Examples + +Resume a `MyKind` resource named `my-res` in the namespace `my-ns`: + +```shell +crossplane resource resume mykind my-res -n my-ns +``` + +Resume all `MyKind` resources in the namespace `my-ns`: + +```shell +crossplane resource resume mykind -n my-ns +``` + +Wide format with full errors, condition messages, and kind-specific columns: + +```shell +crossplane resource resume mykind my-res -n my-ns -o wide +``` + +Show connection secret names alongside the resources: + +```shell +crossplane resource resume mykind my-res -n my-ns --show-connection-secrets +``` + +Output a Graphviz dot graph and pipe to dot to generate a PNG: + +```shell +crossplane resource resume mykind my-res -n my-ns -o dot | dot -Tpng -o output.png +``` + +Output all retrieved resources as JSON and pipe to jq for color: + +```shell +crossplane resource resume mykind my-res -n my-ns -o json | jq +``` + +Output debug logs to stderr while piping a dot graph to dot: + +```shell +crossplane resource resume mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png +``` + +Watch a resource continuously until its deletion: + +```shell +crossplane resource resume mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/trace/help/suspend.md b/cmd/crossplane/trace/help/suspend.md new file mode 100644 index 00000000..8031fdfa --- /dev/null +++ b/cmd/crossplane/trace/help/suspend.md @@ -0,0 +1,120 @@ +The `resource suspend` command pauses the reconciliation a Crossplane resource (Claim, Composite, or +Managed Resource). + +The command requires a resource type and a resource name: + +```shell +crossplane resource suspend +``` + +Kubernetes-style `/` input works too: for example, `crossplane +resource suspend example.crossplane.io/my-xr`. + +You can further specify the kind as `TYPE[.VERSION][.GROUP]` if needed; for +example, `mykind.example.org` or `mykind.v1alpha1.example.org`. + +By default, `crossplane resource suspend` uses the Kubernetes configuration at +`~/.kube/config`. Override with the `KUBECONFIG` environment variable. + +By default the trigger only applies to the requested resource. Using `--cascade` this can be propagated +to all sub resources recursively and can also be combined with `--watch` to follow the status. + +```shell +crossplane resource reconcile --cascade --watch +``` + +## Output options + +By default, `suspend` prints to the terminal as a tree, truncating the `Ready` and +`Status` messages to 64 characters. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +### Wide output + +Use `--output=wide` to print the full `Ready` and `Status` messages even when +they exceed 64 characters, and other kind-specific printer columns. + +### Graphviz dot output + +Use `--output=dot` to print a textual +[Graphviz dot](https://graphviz.org/docs/layouts/dot/) graph. Pipe to `dot` to +render an image: + +```shell +crossplane resource suspend cluster.aws.platformref.upbound.io platform-ref-aws -o dot | dot -Tpng -o graph.png +``` + +## Print connection secrets + +Use `--show-connection-secrets` to include connection-secret names alongside the +other resources. Secret values are never printed. Output includes the secret +name and namespace. + +## Print package dependencies + +The `--show-package-dependencies` flag controls how the display of package +dependencies: + +- `unique` (default): include each required package only once. +- `all`: show every package that requires the same dependency. +- `none`: hide all package dependencies. + +## Print package revisions + +The `--show-package-revisions` flag controls the display of package revisions: + +- `active` (default): show only the active revisions. +- `all`: show all revisions, including inactive ones. +- `none`: hide all revisions. + +## Examples + +Suspend a `MyKind` resource named `my-res` in the namespace `my-ns`: + +```shell +crossplane resource suspend mykind my-res -n my-ns +``` + +Suspend all `MyKind` resources in the namespace `my-ns`: + +```shell +crossplane resource suspend mykind -n my-ns +``` + +Wide format with full errors, condition messages, and kind-specific columns: + +```shell +crossplane resource suspend mykind my-res -n my-ns -o wide +``` + +Show connection secret names alongside the resources: + +```shell +crossplane resource suspend mykind my-res -n my-ns --show-connection-secrets +``` + +Output a Graphviz dot graph and pipe to dot to generate a PNG: + +```shell +crossplane resource suspend mykind my-res -n my-ns -o dot | dot -Tpng -o output.png +``` + +Output all retrieved resources as JSON and pipe to jq for color: + +```shell +crossplane resource suspend mykind my-res -n my-ns -o json | jq +``` + +Output debug logs to stderr while piping a dot graph to dot: + +```shell +crossplane resource suspend mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png +``` + +Watch a resource continuously until its deletion: + +```shell +crossplane resource suspend mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/trace/reconcile.go b/cmd/crossplane/trace/reconcile.go new file mode 100644 index 00000000..c8dbe38a --- /dev/null +++ b/cmd/crossplane/trace/reconcile.go @@ -0,0 +1,188 @@ +/* +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 trace + +import ( + "context" + "time" + + "github.com/alecthomas/kong" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" + + _ "embed" +) + +//go:embed help/reconcile.md +var helpReconcileDetail string + +// Cmd builds the trace tree for a Crossplane resource. +type ReconcileCmd struct { + Cmd + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the trace command. +func (c *ReconcileCmd) Help() string { + return helpReconcileDetail +} + +// Run runs the trace command. +func (c *ReconcileCmd) Run(k *kong.Context, logger logging.Logger) error { + ctx := context.Background() + logger = logger.WithValues("Resource", c.Resource, "Name", c.Name) + + // Init new printer + p, err := printer.New(c.Output) + if err != nil { + return errors.Wrap(err, errInitPrinter) + } + + logger.Debug("Built printer", "output", c.Output) + + clientconfig, client, rmapper, err := c.setupKubeClient(logger) + if err != nil { + return err + } + + res, name, err := c.getResourceAndName() + if err != nil { + return errors.Wrap(err, errInvalidResourceAndName) + } + + mapping, err := internal.MappingFor(rmapper, res) + if err != nil { + return errors.Wrap(err, errGetMapping) + } + + // Get Resource object. Contains k8s resource and all its children, also as Resource. + rootRef := &v1.ObjectReference{ + Kind: mapping.GroupVersionKind.Kind, + APIVersion: mapping.GroupVersionKind.GroupVersion().String(), + Name: name, + } + + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + namespace := c.Namespace + if namespace == "" { + namespace, _, err = clientconfig.Namespace() + if err != nil { + return errors.Wrap(err, errKubeNamespace) + } + } + + logger.Debug("Requested resource is namespaced", "namespace", namespace) + rootRef.Namespace = namespace + } + + // If no name is provided, we should print a list of resources. + shouldPrintAsList := name == "" + + logger.Debug("Getting resource tree", "rootRef", rootRef.String()) + var resourceList *resource.ResourceList + if shouldPrintAsList { + // If no name is provided, we list all resources of the kind. + logger.Debug("No name provided, listing all resources of the kind") + resourceList = resource.ListResources(ctx, client, rootRef) + } else { + // If a name is provided, we get the specific resource. + logger.Debug("Name provided, getting specific resource", "name", name) + res := resource.GetResource(ctx, client, rootRef) + resourceList = &resource.ResourceList{ + Items: []*resource.Resource{res}, + Error: res.Error, + } + } + + // We should just surface any error getting the root resource immediately. + nameDisplay := name + if nameDisplay == "" { + nameDisplay = "" + } + if err := resourceList.Error; err != nil { + return errors.Wrapf(err, errFmtGetResource, mapping.GroupVersionKind.Kind, nameDisplay, rootRef.Namespace) + } + + if c.Cascade { + for i := range resourceList.Items { + root := resourceList.Items[i] + itemKind, itemName, itemNamespace := root.Unstructured.GetKind(), root.Unstructured.GetName(), root.Unstructured.GetNamespace() + root, err = c.getResourceTree(ctx, root, mapping, client, logger) + if err != nil { + logger.Debug(errGetResource, "error", err) + return errors.Wrapf(err, errFmtGetResourceTree, itemKind, itemName, itemNamespace) + } + + logger.Debug("Got resource tree", "root", root) + + resourceList.Items[i] = root + } + } + + if err := c.applyAnnotation(ctx, k, logger, client, resourceList.Items); err != nil { + return err + } + + // Watch mode for a single resource + if c.Watch && !shouldPrintAsList && len(resourceList.Items) > 0 { + root := resourceList.Items[0] + return c.watchResourceTree(ctx, k, logger, client, root, mapping, p) + } + + if shouldPrintAsList { + // Print list of resources + err = p.PrintList(k.Stdout, resourceList) + if err != nil { + return errors.Wrap(err, errCliOutput) + } + // Warn if watch mode was requested with multiple resources + if c.Watch { + if _, err := k.Stdout.Write([]byte("error: you may only watch a single resource at a time\n")); err != nil { + return errors.Wrap(err, errCliOutput) + } + } + return nil + } + + return nil +} + +func (c *ReconcileCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { + for i := range resources { + annotations := resources[i].Unstructured.GetAnnotations() + annotations[crossplanemeta.AnnotationKeyReconcileRequestedAt] = time.Now().Format(time.RFC3339) + delete(annotations, crossplanemeta.AnnotationKeyReconciliationPaused) + resources[i].Unstructured.SetAnnotations(annotations) + + if err := client.Update(ctx, &resources[i].Unstructured); err != nil { + return err + } + + c.applyAnnotation(ctx, k, logger, client, resources[i].Children) + } + + return nil +} diff --git a/cmd/crossplane/trace/resume.go b/cmd/crossplane/trace/resume.go new file mode 100644 index 00000000..d7b93ac3 --- /dev/null +++ b/cmd/crossplane/trace/resume.go @@ -0,0 +1,186 @@ +/* +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 trace + +import ( + "context" + + "github.com/alecthomas/kong" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" + + _ "embed" +) + +//go:embed help/resume.md +var helpResumeDetail string + +// Cmd builds the trace tree for a Crossplane resource. +type ResumeCmd struct { + Cmd + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the trace command. +func (c *ResumeCmd) Help() string { + return helpResumeDetail +} + +// Run runs the trace command. +func (c *ResumeCmd) Run(k *kong.Context, logger logging.Logger) error { + ctx := context.Background() + logger = logger.WithValues("Resource", c.Resource, "Name", c.Name) + + // Init new printer + p, err := printer.New(c.Output) + if err != nil { + return errors.Wrap(err, errInitPrinter) + } + + logger.Debug("Built printer", "output", c.Output) + + clientconfig, client, rmapper, err := c.setupKubeClient(logger) + if err != nil { + return err + } + + res, name, err := c.getResourceAndName() + if err != nil { + return errors.Wrap(err, errInvalidResourceAndName) + } + + mapping, err := internal.MappingFor(rmapper, res) + if err != nil { + return errors.Wrap(err, errGetMapping) + } + + // Get Resource object. Contains k8s resource and all its children, also as Resource. + rootRef := &v1.ObjectReference{ + Kind: mapping.GroupVersionKind.Kind, + APIVersion: mapping.GroupVersionKind.GroupVersion().String(), + Name: name, + } + + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + namespace := c.Namespace + if namespace == "" { + namespace, _, err = clientconfig.Namespace() + if err != nil { + return errors.Wrap(err, errKubeNamespace) + } + } + + logger.Debug("Requested resource is namespaced", "namespace", namespace) + rootRef.Namespace = namespace + } + + // If no name is provided, we should print a list of resources. + shouldPrintAsList := name == "" + + logger.Debug("Getting resource tree", "rootRef", rootRef.String()) + var resourceList *resource.ResourceList + if shouldPrintAsList { + // If no name is provided, we list all resources of the kind. + logger.Debug("No name provided, listing all resources of the kind") + resourceList = resource.ListResources(ctx, client, rootRef) + } else { + // If a name is provided, we get the specific resource. + logger.Debug("Name provided, getting specific resource", "name", name) + res := resource.GetResource(ctx, client, rootRef) + resourceList = &resource.ResourceList{ + Items: []*resource.Resource{res}, + Error: res.Error, + } + } + + // We should just surface any error getting the root resource immediately. + nameDisplay := name + if nameDisplay == "" { + nameDisplay = "" + } + if err := resourceList.Error; err != nil { + return errors.Wrapf(err, errFmtGetResource, mapping.GroupVersionKind.Kind, nameDisplay, rootRef.Namespace) + } + + if c.Cascade { + for i := range resourceList.Items { + root := resourceList.Items[i] + itemKind, itemName, itemNamespace := root.Unstructured.GetKind(), root.Unstructured.GetName(), root.Unstructured.GetNamespace() + root, err = c.getResourceTree(ctx, root, mapping, client, logger) + if err != nil { + logger.Debug(errGetResource, "error", err) + return errors.Wrapf(err, errFmtGetResourceTree, itemKind, itemName, itemNamespace) + } + + logger.Debug("Got resource tree", "root", root) + + resourceList.Items[i] = root + } + } + + if err := c.applyAnnotation(ctx, k, logger, client, resourceList.Items); err != nil { + return err + } + + // Watch mode for a single resource + if c.Watch && !shouldPrintAsList && len(resourceList.Items) > 0 { + root := resourceList.Items[0] + return c.watchResourceTree(ctx, k, logger, client, root, mapping, p) + } + + if shouldPrintAsList { + // Print list of resources + err = p.PrintList(k.Stdout, resourceList) + if err != nil { + return errors.Wrap(err, errCliOutput) + } + // Warn if watch mode was requested with multiple resources + if c.Watch { + if _, err := k.Stdout.Write([]byte("error: you may only watch a single resource at a time\n")); err != nil { + return errors.Wrap(err, errCliOutput) + } + } + return nil + } + + return nil +} + +func (c *ResumeCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { + for i := range resources { + annotations := resources[i].Unstructured.GetAnnotations() + delete(annotations, crossplanemeta.AnnotationKeyReconciliationPaused) + resources[i].Unstructured.SetAnnotations(annotations) + + if err := client.Update(ctx, &resources[i].Unstructured); err != nil { + return err + } + + c.applyAnnotation(ctx, k, logger, client, resources[i].Children) + } + + return nil +} diff --git a/cmd/crossplane/trace/suspend.go b/cmd/crossplane/trace/suspend.go new file mode 100644 index 00000000..ba1e967b --- /dev/null +++ b/cmd/crossplane/trace/suspend.go @@ -0,0 +1,185 @@ +/* +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 trace + +import ( + "context" + + "github.com/alecthomas/kong" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" + + _ "embed" +) + +//go:embed help/suspend.md +var helpSuspendDetail string + +// Cmd builds the trace tree for a Crossplane resource. +type SuspendCmd struct { + Cmd + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the trace command. +func (c *SuspendCmd) Help() string { + return helpSuspendDetail +} + +// Run runs the trace command. +func (c *SuspendCmd) Run(k *kong.Context, logger logging.Logger) error { + ctx := context.Background() + logger = logger.WithValues("Resource", c.Resource, "Name", c.Name) + + // Init new printer + p, err := printer.New(c.Output) + if err != nil { + return errors.Wrap(err, errInitPrinter) + } + + logger.Debug("Built printer", "output", c.Output) + + clientconfig, client, rmapper, err := c.setupKubeClient(logger) + if err != nil { + return err + } + + res, name, err := c.getResourceAndName() + if err != nil { + return errors.Wrap(err, errInvalidResourceAndName) + } + + mapping, err := internal.MappingFor(rmapper, res) + if err != nil { + return errors.Wrap(err, errGetMapping) + } + + // Get Resource object. Contains k8s resource and all its children, also as Resource. + rootRef := &v1.ObjectReference{ + Kind: mapping.GroupVersionKind.Kind, + APIVersion: mapping.GroupVersionKind.GroupVersion().String(), + Name: name, + } + + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + namespace := c.Namespace + if namespace == "" { + namespace, _, err = clientconfig.Namespace() + if err != nil { + return errors.Wrap(err, errKubeNamespace) + } + } + + logger.Debug("Requested resource is namespaced", "namespace", namespace) + rootRef.Namespace = namespace + } + // If no name is provided, we should print a list of resources. + shouldPrintAsList := name == "" + + logger.Debug("Getting resource tree", "rootRef", rootRef.String()) + var resourceList *resource.ResourceList + if shouldPrintAsList { + // If no name is provided, we list all resources of the kind. + logger.Debug("No name provided, listing all resources of the kind") + resourceList = resource.ListResources(ctx, client, rootRef) + } else { + // If a name is provided, we get the specific resource. + logger.Debug("Name provided, getting specific resource", "name", name) + res := resource.GetResource(ctx, client, rootRef) + resourceList = &resource.ResourceList{ + Items: []*resource.Resource{res}, + Error: res.Error, + } + } + + // We should just surface any error getting the root resource immediately. + nameDisplay := name + if nameDisplay == "" { + nameDisplay = "" + } + if err := resourceList.Error; err != nil { + return errors.Wrapf(err, errFmtGetResource, mapping.GroupVersionKind.Kind, nameDisplay, rootRef.Namespace) + } + + if c.Cascade { + for i := range resourceList.Items { + root := resourceList.Items[i] + itemKind, itemName, itemNamespace := root.Unstructured.GetKind(), root.Unstructured.GetName(), root.Unstructured.GetNamespace() + root, err = c.getResourceTree(ctx, root, mapping, client, logger) + if err != nil { + logger.Debug(errGetResource, "error", err) + return errors.Wrapf(err, errFmtGetResourceTree, itemKind, itemName, itemNamespace) + } + + logger.Debug("Got resource tree", "root", root) + + resourceList.Items[i] = root + } + } + + if err := c.applyAnnotation(ctx, k, logger, client, resourceList.Items); err != nil { + return err + } + + // Watch mode for a single resource + if c.Watch && !shouldPrintAsList && len(resourceList.Items) > 0 { + root := resourceList.Items[0] + return c.watchResourceTree(ctx, k, logger, client, root, mapping, p) + } + + if shouldPrintAsList { + // Print list of resources + err = p.PrintList(k.Stdout, resourceList) + if err != nil { + return errors.Wrap(err, errCliOutput) + } + // Warn if watch mode was requested with multiple resources + if c.Watch { + if _, err := k.Stdout.Write([]byte("error: you may only watch a single resource at a time\n")); err != nil { + return errors.Wrap(err, errCliOutput) + } + } + return nil + } + + return nil +} + +func (c *SuspendCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { + for i := range resources { + annotations := resources[i].Unstructured.GetAnnotations() + annotations[crossplanemeta.AnnotationKeyReconciliationPaused] = "true" + resources[i].Unstructured.SetAnnotations(annotations) + + if err := client.Update(ctx, &resources[i].Unstructured); err != nil { + return err + } + + c.applyAnnotation(ctx, k, logger, client, resources[i].Children) + } + + return nil +} From 95ea3696acc3e4e4d224c6f0d48a8c77d9bdaff7 Mon Sep 17 00:00:00 2001 From: Raffael Sahli Date: Mon, 21 Sep 2026 14:38:21 +0200 Subject: [PATCH 2/2] fix: nil map in case no annotations Signed-off-by: Raffael Sahli --- cmd/crossplane/trace/reconcile.go | 4 ++++ cmd/crossplane/trace/resume.go | 4 ++++ cmd/crossplane/trace/suspend.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/cmd/crossplane/trace/reconcile.go b/cmd/crossplane/trace/reconcile.go index c8dbe38a..db484690 100644 --- a/cmd/crossplane/trace/reconcile.go +++ b/cmd/crossplane/trace/reconcile.go @@ -173,6 +173,10 @@ func (c *ReconcileCmd) Run(k *kong.Context, logger logging.Logger) error { func (c *ReconcileCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { for i := range resources { annotations := resources[i].Unstructured.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[crossplanemeta.AnnotationKeyReconcileRequestedAt] = time.Now().Format(time.RFC3339) delete(annotations, crossplanemeta.AnnotationKeyReconciliationPaused) resources[i].Unstructured.SetAnnotations(annotations) diff --git a/cmd/crossplane/trace/resume.go b/cmd/crossplane/trace/resume.go index d7b93ac3..7c241fb9 100644 --- a/cmd/crossplane/trace/resume.go +++ b/cmd/crossplane/trace/resume.go @@ -172,6 +172,10 @@ func (c *ResumeCmd) Run(k *kong.Context, logger logging.Logger) error { func (c *ResumeCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { for i := range resources { annotations := resources[i].Unstructured.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + delete(annotations, crossplanemeta.AnnotationKeyReconciliationPaused) resources[i].Unstructured.SetAnnotations(annotations) diff --git a/cmd/crossplane/trace/suspend.go b/cmd/crossplane/trace/suspend.go index ba1e967b..5110e413 100644 --- a/cmd/crossplane/trace/suspend.go +++ b/cmd/crossplane/trace/suspend.go @@ -171,6 +171,10 @@ func (c *SuspendCmd) Run(k *kong.Context, logger logging.Logger) error { func (c *SuspendCmd) applyAnnotation(ctx context.Context, k *kong.Context, logger logging.Logger, client client.Client, resources []*resource.Resource) error { for i := range resources { annotations := resources[i].Unstructured.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[crossplanemeta.AnnotationKeyReconciliationPaused] = "true" resources[i].Unstructured.SetAnnotations(annotations)