diff --git a/cmd/crossplane/resource/resource.go b/cmd/crossplane/resource/resource.go index 5186ad61..7608e848 100644 --- a/cmd/crossplane/resource/resource.go +++ b/cmd/crossplane/resource/resource.go @@ -19,13 +19,16 @@ 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/tree" "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."` + Simulate simulate.Cmd `cmd:"" help:"Preview what applying managed resources would do." maturity:"alpha"` + Trace tree.TraceCmd `cmd:"" help:"Trace a Crossplane resource for troubleshooting."` + Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` + Suspend tree.SuspendCmd `cmd:"" help:"Suspend crossplane resource reconciliation."` + Resume tree.ResumeCmd `cmd:"" help:"Resume reconciliation for suspended crossplane resources."` + Reconcile tree.ReconcileCmd `cmd:"" help:"Trigger an immediate reconciliation for a crossplane resource."` } diff --git a/cmd/crossplane/trace/trace_test.go b/cmd/crossplane/trace/trace_test.go deleted file mode 100644 index 4143a593..00000000 --- a/cmd/crossplane/trace/trace_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package trace - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - - "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - "github.com/crossplane/crossplane-runtime/v2/pkg/test" -) - -func TestCmd_getResourceAndName(t *testing.T) { - type args struct { - Resource string - Name string - } - - type want struct { - resource string - name string - err error - } - - tests := map[string]struct { - reason string - fields args - want want - }{ - "Splitted": { - reason: "Should return the resource and name if both are provided", - fields: args{ - Resource: "resource", - Name: "name", - }, - want: want{ - resource: "resource", - name: "name", - err: nil, - }, - }, - "Empty": { - // should never happen, resource is required by kong - reason: "Should return an error if no resource is provided", - fields: args{ - Resource: "", - Name: "", - }, - want: want{ - err: errors.New(errInvalidResource), - }, - }, - "Combined": { - reason: "Should return the resource and name if both are provided combined as resource", - fields: args{ - Resource: "resource/name", - Name: "", - }, - want: want{ - resource: "resource", - name: "name", - }, - }, - "MoreSlashes": { - reason: "Should return an error if the resource contains more than one slashes", - fields: args{ - Resource: "resource/name/other", - Name: "", - }, - want: want{ - err: errors.New(errInvalidResource), - }, - }, - "BothAndCombined": { - reason: "Should return an error if a name is provided both in the resource and separately", - fields: args{ - Resource: "resource/name", - Name: "name", - }, - want: want{ - err: errors.New(errNameDoubled), - }, - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - c := &Cmd{ - Resource: tt.fields.Resource, - Name: tt.fields.Name, - } - - gotResource, gotName, err := c.getResourceAndName() - if diff := cmp.Diff(tt.want.err, err, test.EquateErrors()); diff != "" { - t.Errorf("Cmd.getResourceAndName() error = %v, wantErr %v", err, tt.want.err) - } - - if diff := cmp.Diff(tt.want.resource, gotResource); diff != "" { - t.Errorf("Cmd.getResourceAndName() resource = %v, want %v", gotResource, tt.want.resource) - } - - if diff := cmp.Diff(tt.want.name, gotName); diff != "" { - t.Errorf("Cmd.getResourceAndName() name = %v, want %v", gotName, tt.want.name) - } - }) - } -} diff --git a/cmd/crossplane/tree/annotate.go b/cmd/crossplane/tree/annotate.go new file mode 100644 index 00000000..017adae9 --- /dev/null +++ b/cmd/crossplane/tree/annotate.go @@ -0,0 +1,46 @@ +/* +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 tree + +import ( + "context" + "encoding/json" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// patchAnnotations sets and removes annotations. +func patchAnnotations(ctx context.Context, c client.Client, u *unstructured.Unstructured, annotations map[string]*string) error { + data, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "annotations": annotations, + }, + }) + if err != nil { + return errors.Wrapf(err, errFmtUpdateResource, u.GetKind(), u.GetName(), u.GetNamespace()) + } + + if err := c.Patch(ctx, u, client.RawPatch(types.MergePatchType, data)); err != nil { + return errors.Wrapf(err, errFmtUpdateResource, u.GetKind(), u.GetName(), u.GetNamespace()) + } + + return nil +} diff --git a/cmd/crossplane/tree/help/reconcile.md b/cmd/crossplane/tree/help/reconcile.md new file mode 100644 index 00000000..11a9cd76 --- /dev/null +++ b/cmd/crossplane/tree/help/reconcile.md @@ -0,0 +1,69 @@ +The `resource reconcile` command triggers an immediate reconciliation of a Crossplane resource (Claim, +Composite, or Managed Resource) by setting the +`crossplane.io/reconcile-requested-at` annotation. + +Paused resources (with the `crossplane.io/paused: "true"` annotation) are not +reconciled. If any affected resource is paused, including descendants when +using `--cascade`, the command fails without changing anything. Use +`crossplane resource resume` to resume and reconcile paused resources. + +The command requires a resource type. The resource name is optional; when it +is omitted, the command applies to all resources of that type: + +```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. + +## Cascade + +By default, `reconcile` only applies to the requested resource. Use `--cascade` +to reconcile the whole resource tree recursively. Combine it with `--watch` to +follow the status afterwards: + +```shell +crossplane resource reconcile --cascade --watch +``` + +## Output options + +After applying the change, `reconcile` prints the affected resources to the +terminal as a tree, the same way `crossplane resource trace` does. Without +`--cascade` only the requested resources are printed. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +## 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 +``` + +Reconcile a resource and all of its descendants: + +```shell +crossplane resource reconcile mykind my-res -n my-ns --cascade +``` + +Reconcile a resource and watch it continuously until its deletion: + +```shell +crossplane resource reconcile mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/tree/help/resume.md b/cmd/crossplane/tree/help/resume.md new file mode 100644 index 00000000..bbbc036c --- /dev/null +++ b/cmd/crossplane/tree/help/resume.md @@ -0,0 +1,66 @@ +The `resource resume` command resumes the reconciliation of a paused Crossplane resource (Claim, +Composite, or Managed Resource) by removing the `crossplane.io/paused` annotation. +It also sets the `crossplane.io/reconcile-requested-at` annotation to the +current time, so the resource is reconciled right away instead of waiting for +the next poll interval. + +The command requires a resource type. The resource name is optional; when it +is omitted, the command applies to all resources of that type: + +```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. + +## Cascade + +By default, `resume` only applies to the requested resource. Use `--cascade` +to resume the whole resource tree recursively. Combine it with `--watch` to +follow the status afterwards: + +```shell +crossplane resource resume --cascade --watch +``` + +## Output options + +After applying the change, `resume` prints the affected resources to the +terminal as a tree, the same way `crossplane resource trace` does. Without +`--cascade` only the requested resources are printed. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +## 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 +``` + +Resume a resource and all of its descendants: + +```shell +crossplane resource resume mykind my-res -n my-ns --cascade +``` + +Resume a resource and watch it continuously until its deletion: + +```shell +crossplane resource resume mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/tree/help/suspend.md b/cmd/crossplane/tree/help/suspend.md new file mode 100644 index 00000000..dd58fafe --- /dev/null +++ b/cmd/crossplane/tree/help/suspend.md @@ -0,0 +1,63 @@ +The `resource suspend` command pauses the reconciliation of a Crossplane resource (Claim, Composite, or +Managed Resource) by setting the `crossplane.io/paused` annotation. + +The command requires a resource type. The resource name is optional; when it +is omitted, the command applies to all resources of that type: + +```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. + +## Cascade + +By default, `suspend` only applies to the requested resource. Use `--cascade` +to suspend the whole resource tree recursively. Combine it with `--watch` to +follow the status afterwards: + +```shell +crossplane resource suspend --cascade --watch +``` + +## Output options + +After applying the change, `suspend` prints the affected resources to the +terminal as a tree, the same way `crossplane resource trace` does. Without +`--cascade` only the requested resources are printed. + +Change the format with `-o` (`--output`): `wide`, `json`, `yaml`, or `dot` (for +a [Graphviz](https://graphviz.org/docs/layouts/dot/) graph). + +## 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 +``` + +Suspend a resource and all of its descendants: + +```shell +crossplane resource suspend mykind my-res -n my-ns --cascade +``` + +Suspend a resource and watch it continuously until its deletion: + +```shell +crossplane resource suspend mykind my-res -n my-ns --watch +``` diff --git a/cmd/crossplane/trace/help/trace.md b/cmd/crossplane/tree/help/trace.md similarity index 100% rename from cmd/crossplane/trace/help/trace.md rename to cmd/crossplane/tree/help/trace.md diff --git a/cmd/crossplane/tree/helpers_test.go b/cmd/crossplane/tree/helpers_test.go new file mode 100644 index 00000000..9164f400 --- /dev/null +++ b/cmd/crossplane/tree/helpers_test.go @@ -0,0 +1,63 @@ +package tree + +import ( + "context" + "encoding/json" + "maps" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/pkg/resource" +) + +// newTestResource returns a resource with the supplied name, annotations and children. +func newTestResource(name string, annotations map[string]string, children ...*resource.Resource) *resource.Resource { + u := unstructured.Unstructured{} + u.SetAPIVersion("example.org/v1") + u.SetKind("Example") + u.SetName(name) + if annotations != nil { + u.SetAnnotations(annotations) + } + + return &resource.Resource{Unstructured: u, Children: children} +} + +// recordingClient returns a mock client that records the annotations each +// patched object would have after applying the merge patch, keyed by object +// name. If failOn is non-empty, patching the object with that name returns err. +func recordingClient(updated map[string]map[string]string, failOn string, err error) *test.MockClient { + return &test.MockClient{ + MockPatch: func(_ context.Context, obj client.Object, patch client.Patch, _ ...client.PatchOption) error { + if obj.GetName() == failOn { + return err + } + data, perr := patch.Data(obj) + if perr != nil { + return perr + } + p := struct { + Metadata struct { + Annotations map[string]*string `json:"annotations"` + } `json:"metadata"` + }{} + if perr := json.Unmarshal(data, &p); perr != nil { + return perr + } + result := map[string]string{} + maps.Copy(result, obj.GetAnnotations()) + for k, v := range p.Metadata.Annotations { + if v == nil { + delete(result, k) + continue + } + result[k] = *v + } + updated[obj.GetName()] = result + return nil + }, + } +} diff --git a/cmd/crossplane/trace/internal/printer/default.go b/cmd/crossplane/tree/internal/printer/default.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/default.go rename to cmd/crossplane/tree/internal/printer/default.go diff --git a/cmd/crossplane/trace/internal/printer/default_test.go b/cmd/crossplane/tree/internal/printer/default_test.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/default_test.go rename to cmd/crossplane/tree/internal/printer/default_test.go diff --git a/cmd/crossplane/trace/internal/printer/dot.go b/cmd/crossplane/tree/internal/printer/dot.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/dot.go rename to cmd/crossplane/tree/internal/printer/dot.go diff --git a/cmd/crossplane/trace/internal/printer/dot_test.go b/cmd/crossplane/tree/internal/printer/dot_test.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/dot_test.go rename to cmd/crossplane/tree/internal/printer/dot_test.go diff --git a/cmd/crossplane/trace/internal/printer/json.go b/cmd/crossplane/tree/internal/printer/json.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/json.go rename to cmd/crossplane/tree/internal/printer/json.go diff --git a/cmd/crossplane/trace/internal/printer/json_test.go b/cmd/crossplane/tree/internal/printer/json_test.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/json_test.go rename to cmd/crossplane/tree/internal/printer/json_test.go diff --git a/cmd/crossplane/trace/internal/printer/printer.go b/cmd/crossplane/tree/internal/printer/printer.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/printer.go rename to cmd/crossplane/tree/internal/printer/printer.go diff --git a/cmd/crossplane/trace/internal/printer/printer_test.go b/cmd/crossplane/tree/internal/printer/printer_test.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/printer_test.go rename to cmd/crossplane/tree/internal/printer/printer_test.go diff --git a/cmd/crossplane/trace/internal/printer/yaml.go b/cmd/crossplane/tree/internal/printer/yaml.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/yaml.go rename to cmd/crossplane/tree/internal/printer/yaml.go diff --git a/cmd/crossplane/trace/internal/printer/yaml_test.go b/cmd/crossplane/tree/internal/printer/yaml_test.go similarity index 100% rename from cmd/crossplane/trace/internal/printer/yaml_test.go rename to cmd/crossplane/tree/internal/printer/yaml_test.go diff --git a/cmd/crossplane/tree/reconcile.go b/cmd/crossplane/tree/reconcile.go new file mode 100644 index 00000000..ba36ae56 --- /dev/null +++ b/cmd/crossplane/tree/reconcile.go @@ -0,0 +1,198 @@ +/* +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 tree + +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/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" + "github.com/crossplane/cli/v2/pkg/resource" + + _ "embed" +) + +//go:embed help/reconcile.md +var helpReconcileDetail string + +// ReconcileCmd triggers an immediate reconciliation of a Crossplane resource. +type ReconcileCmd struct { + TraceCmd + + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the reconcile command. +func (c *ReconcileCmd) Help() string { + return helpReconcileDetail +} + +// Run runs the reconcile 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 + } + } + + // Refuse to reconcile paused resources rather than silently resuming them. + // Check the whole tree before patching anything so we never leave it + // partially updated. + if err := checkNotPaused(resourceList.Items); err != nil { + return err + } + + if err := c.applyAnnotation(ctx, 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) + } + + return printResources(k.Stdout, p, resourceList, shouldPrintAsList, c.Watch) +} + +func (c *ReconcileCmd) applyAnnotation(ctx context.Context, client client.Client, resources []*resource.Resource) error { + for i := range resources { + requestedAt := time.Now().Format(time.RFC3339) + if err := patchAnnotations(ctx, client, &resources[i].Unstructured, map[string]*string{ + crossplanemeta.AnnotationKeyReconcileRequestedAt: &requestedAt, + }); err != nil { + return err + } + + if err := c.applyAnnotation(ctx, client, resources[i].Children); err != nil { + return err + } + } + + return nil +} + +// checkNotPaused returns an error for the first resource in the supplied trees +// that has reconciliation paused. +func checkNotPaused(resources []*resource.Resource) error { + for _, r := range resources { + u := &r.Unstructured + if crossplanemeta.IsPaused(u) { + return errors.Errorf(errFmtResourcePaused, u.GetKind(), u.GetName(), u.GetNamespace()) + } + + if err := checkNotPaused(r.Children); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/crossplane/tree/reconcile_test.go b/cmd/crossplane/tree/reconcile_test.go new file mode 100644 index 00000000..becb1811 --- /dev/null +++ b/cmd/crossplane/tree/reconcile_test.go @@ -0,0 +1,160 @@ +package tree + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/pkg/resource" +) + +func TestReconcileCmd_applyAnnotation(t *testing.T) { + errBoom := errors.New("boom") + requestedAt := crossplanemeta.AnnotationKeyReconcileRequestedAt + + // The reconcile-requested-at annotation contains the current time, so we + // replace it with this placeholder after checking it is a valid timestamp. + const timestamp = "" + + type want struct { + updated map[string]map[string]string + err error + } + + tests := map[string]struct { + reason string + resources []*resource.Resource + failOn string + want want + }{ + "NilAnnotations": { + reason: "Should add the reconcile-requested-at annotation to a resource without annotations", + resources: []*resource.Resource{newTestResource("root", nil)}, + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + }, + }, + }, + "ExistingAnnotations": { + reason: "Should add reconcile-requested-at and keep other annotations", + resources: []*resource.Resource{newTestResource("root", map[string]string{"foo": "bar"})}, + want: want{ + updated: map[string]map[string]string{ + "root": {"foo": "bar", requestedAt: timestamp}, + }, + }, + }, + "Children": { + reason: "Should add the reconcile-requested-at annotation to all resources in the tree", + resources: []*resource.Resource{ + newTestResource("root", nil, + newTestResource("child", nil, + newTestResource("grandchild", nil))), + }, + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + "child": {requestedAt: timestamp}, + "grandchild": {requestedAt: timestamp}, + }, + }, + }, + "UpdateError": { + reason: "Should return an error if updating a resource fails", + resources: []*resource.Resource{newTestResource("root", nil)}, + failOn: "root", + want: want{ + updated: map[string]map[string]string{}, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "root", ""), + }, + }, + "ChildUpdateError": { + reason: "Should return an error if updating a child resource fails", + resources: []*resource.Resource{ + newTestResource("root", nil, newTestResource("child", nil)), + }, + failOn: "child", + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + }, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "child", ""), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + updated := map[string]map[string]string{} + c := &ReconcileCmd{} + + err := c.applyAnnotation(context.Background(), recordingClient(updated, tc.failOn, errBoom), tc.resources) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want error, +got error:\n%s", tc.reason, diff) + } + + for obj, annotations := range updated { + v, ok := annotations[requestedAt] + if !ok { + continue + } + if _, err := time.Parse(time.RFC3339, v); err != nil { + t.Errorf("\n%s\napplyAnnotation(...): %s annotation of %q is not a valid RFC3339 timestamp: %v", tc.reason, requestedAt, obj, err) + } + annotations[requestedAt] = timestamp + } + + if diff := cmp.Diff(tc.want.updated, updated); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want updated, +got updated:\n%s", tc.reason, diff) + } + }) + } +} + +func TestCheckNotPaused(t *testing.T) { + paused := crossplanemeta.AnnotationKeyReconciliationPaused + + tests := map[string]struct { + reason string + resources []*resource.Resource + want error + }{ + "NotPaused": { + reason: "Should not return an error if no resource in the tree is paused", + resources: []*resource.Resource{ + newTestResource("root", nil, + newTestResource("child", map[string]string{paused: "false"})), + }, + }, + "RootPaused": { + reason: "Should return an error if the root resource is paused", + resources: []*resource.Resource{newTestResource("root", map[string]string{paused: "true"})}, + want: errors.Errorf(errFmtResourcePaused, "Example", "root", ""), + }, + "DescendantPaused": { + reason: "Should return an error if a descendant resource is paused", + resources: []*resource.Resource{ + newTestResource("root", nil, + newTestResource("child", nil, + newTestResource("grandchild", map[string]string{paused: "true"}))), + }, + want: errors.Errorf(errFmtResourcePaused, "Example", "grandchild", ""), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := checkNotPaused(tc.resources) + if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\ncheckNotPaused(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/tree/resume.go b/cmd/crossplane/tree/resume.go new file mode 100644 index 00000000..6707388e --- /dev/null +++ b/cmd/crossplane/tree/resume.go @@ -0,0 +1,177 @@ +/* +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 tree + +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/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" + "github.com/crossplane/cli/v2/pkg/resource" + + _ "embed" +) + +//go:embed help/resume.md +var helpResumeDetail string + +// ResumeCmd resumes reconciliation of a suspended Crossplane resource. +type ResumeCmd struct { + TraceCmd + + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the resume command. +func (c *ResumeCmd) Help() string { + return helpResumeDetail +} + +// Run runs the resume 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, 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) + } + + return printResources(k.Stdout, p, resourceList, shouldPrintAsList, c.Watch) +} + +func (c *ResumeCmd) applyAnnotation(ctx context.Context, client client.Client, resources []*resource.Resource) error { + for i := range resources { + // Request an immediate reconcile so the resource does not wait for the + // next poll interval after being unpaused. + requestedAt := time.Now().Format(time.RFC3339) + if err := patchAnnotations(ctx, client, &resources[i].Unstructured, map[string]*string{ + crossplanemeta.AnnotationKeyReconciliationPaused: nil, + crossplanemeta.AnnotationKeyReconcileRequestedAt: &requestedAt, + }); err != nil { + return err + } + + if err := c.applyAnnotation(ctx, client, resources[i].Children); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/crossplane/tree/resume_test.go b/cmd/crossplane/tree/resume_test.go new file mode 100644 index 00000000..a0b69143 --- /dev/null +++ b/cmd/crossplane/tree/resume_test.go @@ -0,0 +1,122 @@ +package tree + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/pkg/resource" +) + +func TestResumeCmd_applyAnnotation(t *testing.T) { + errBoom := errors.New("boom") + paused := crossplanemeta.AnnotationKeyReconciliationPaused + requestedAt := crossplanemeta.AnnotationKeyReconcileRequestedAt + + // The reconcile-requested-at annotation contains the current time, so we + // replace it with this placeholder after checking it is a valid timestamp. + const timestamp = "" + + type want struct { + updated map[string]map[string]string + err error + } + + tests := map[string]struct { + reason string + resources []*resource.Resource + failOn string + want want + }{ + "NilAnnotations": { + reason: "Should request a reconcile on a resource without annotations", + resources: []*resource.Resource{newTestResource("root", nil)}, + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + }, + }, + }, + "Paused": { + reason: "Should remove the paused annotation, request a reconcile and keep other annotations", + resources: []*resource.Resource{newTestResource("root", map[string]string{"foo": "bar", paused: "true"})}, + want: want{ + updated: map[string]map[string]string{ + "root": {"foo": "bar", requestedAt: timestamp}, + }, + }, + }, + "Children": { + reason: "Should remove the paused annotation and request a reconcile for all resources in the tree", + resources: []*resource.Resource{ + newTestResource("root", map[string]string{paused: "true"}, + newTestResource("child", map[string]string{paused: "true", "foo": "bar"}, + newTestResource("grandchild", map[string]string{paused: "true"}))), + }, + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + "child": {"foo": "bar", requestedAt: timestamp}, + "grandchild": {requestedAt: timestamp}, + }, + }, + }, + "UpdateError": { + reason: "Should return an error if updating a resource fails", + resources: []*resource.Resource{newTestResource("root", map[string]string{paused: "true"})}, + failOn: "root", + want: want{ + updated: map[string]map[string]string{}, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "root", ""), + }, + }, + "ChildUpdateError": { + reason: "Should return an error if updating a child resource fails", + resources: []*resource.Resource{ + newTestResource("root", map[string]string{paused: "true"}, + newTestResource("child", map[string]string{paused: "true"})), + }, + failOn: "child", + want: want{ + updated: map[string]map[string]string{ + "root": {requestedAt: timestamp}, + }, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "child", ""), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + updated := map[string]map[string]string{} + c := &ResumeCmd{} + + err := c.applyAnnotation(context.Background(), recordingClient(updated, tc.failOn, errBoom), tc.resources) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want error, +got error:\n%s", tc.reason, diff) + } + + for obj, annotations := range updated { + v, ok := annotations[requestedAt] + if !ok { + t.Errorf("\n%s\napplyAnnotation(...): %q is missing the %s annotation", tc.reason, obj, requestedAt) + continue + } + if _, err := time.Parse(time.RFC3339, v); err != nil { + t.Errorf("\n%s\napplyAnnotation(...): %s annotation of %q is not a valid RFC3339 timestamp: %v", tc.reason, requestedAt, obj, err) + } + annotations[requestedAt] = timestamp + } + + if diff := cmp.Diff(tc.want.updated, updated); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want updated, +got updated:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/tree/suspend.go b/cmd/crossplane/tree/suspend.go new file mode 100644 index 00000000..2ff63d52 --- /dev/null +++ b/cmd/crossplane/tree/suspend.go @@ -0,0 +1,172 @@ +/* +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 tree + +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/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" + "github.com/crossplane/cli/v2/pkg/resource" + + _ "embed" +) + +//go:embed help/suspend.md +var helpSuspendDetail string + +// SuspendCmd pauses reconciliation of a Crossplane resource. +type SuspendCmd struct { + TraceCmd + + Cascade bool `help:"Recursively apply to the resource tree." name:"cascade"` +} + +// Help returns help message for the suspend command. +func (c *SuspendCmd) Help() string { + return helpSuspendDetail +} + +// Run runs the suspend 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, 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) + } + + return printResources(k.Stdout, p, resourceList, shouldPrintAsList, c.Watch) +} + +func (c *SuspendCmd) applyAnnotation(ctx context.Context, client client.Client, resources []*resource.Resource) error { + for i := range resources { + paused := "true" + if err := patchAnnotations(ctx, client, &resources[i].Unstructured, map[string]*string{ + crossplanemeta.AnnotationKeyReconciliationPaused: &paused, + }); err != nil { + return err + } + + if err := c.applyAnnotation(ctx, client, resources[i].Children); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/crossplane/tree/suspend_test.go b/cmd/crossplane/tree/suspend_test.go new file mode 100644 index 00000000..50bee861 --- /dev/null +++ b/cmd/crossplane/tree/suspend_test.go @@ -0,0 +1,103 @@ +package tree + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + crossplanemeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/pkg/resource" +) + +func TestSuspendCmd_applyAnnotation(t *testing.T) { + errBoom := errors.New("boom") + paused := crossplanemeta.AnnotationKeyReconciliationPaused + + type want struct { + updated map[string]map[string]string + err error + } + + tests := map[string]struct { + reason string + resources []*resource.Resource + failOn string + want want + }{ + "NilAnnotations": { + reason: "Should add the paused annotation to a resource without annotations", + resources: []*resource.Resource{newTestResource("root", nil)}, + want: want{ + updated: map[string]map[string]string{ + "root": {paused: "true"}, + }, + }, + }, + "ExistingAnnotations": { + reason: "Should add the paused annotation and keep existing annotations", + resources: []*resource.Resource{newTestResource("root", map[string]string{"foo": "bar"})}, + want: want{ + updated: map[string]map[string]string{ + "root": {"foo": "bar", paused: "true"}, + }, + }, + }, + "Children": { + reason: "Should add the paused annotation to all resources in the tree", + resources: []*resource.Resource{ + newTestResource("root", nil, + newTestResource("child", nil, + newTestResource("grandchild", nil))), + }, + want: want{ + updated: map[string]map[string]string{ + "root": {paused: "true"}, + "child": {paused: "true"}, + "grandchild": {paused: "true"}, + }, + }, + }, + "UpdateError": { + reason: "Should return an error if updating a resource fails", + resources: []*resource.Resource{newTestResource("root", nil)}, + failOn: "root", + want: want{ + updated: map[string]map[string]string{}, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "root", ""), + }, + }, + "ChildUpdateError": { + reason: "Should return an error if updating a child resource fails", + resources: []*resource.Resource{ + newTestResource("root", nil, newTestResource("child", nil)), + }, + failOn: "child", + want: want{ + updated: map[string]map[string]string{ + "root": {paused: "true"}, + }, + err: errors.Wrapf(errBoom, errFmtUpdateResource, "Example", "child", ""), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + updated := map[string]map[string]string{} + c := &SuspendCmd{} + + err := c.applyAnnotation(context.Background(), recordingClient(updated, tc.failOn, errBoom), tc.resources) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.updated, updated); diff != "" { + t.Errorf("\n%s\napplyAnnotation(...): -want updated, +got updated:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/trace/testdata/integration/composed-failing.yaml b/cmd/crossplane/tree/testdata/integration/composed-failing.yaml similarity index 100% rename from cmd/crossplane/trace/testdata/integration/composed-failing.yaml rename to cmd/crossplane/tree/testdata/integration/composed-failing.yaml diff --git a/cmd/crossplane/trace/testdata/integration/composed-ready.yaml b/cmd/crossplane/tree/testdata/integration/composed-ready.yaml similarity index 100% rename from cmd/crossplane/trace/testdata/integration/composed-ready.yaml rename to cmd/crossplane/tree/testdata/integration/composed-ready.yaml diff --git a/cmd/crossplane/trace/testdata/integration/crds/nopresource.yaml b/cmd/crossplane/tree/testdata/integration/crds/nopresource.yaml similarity index 100% rename from cmd/crossplane/trace/testdata/integration/crds/nopresource.yaml rename to cmd/crossplane/tree/testdata/integration/crds/nopresource.yaml diff --git a/cmd/crossplane/trace/testdata/integration/setup.yaml b/cmd/crossplane/tree/testdata/integration/setup.yaml similarity index 100% rename from cmd/crossplane/trace/testdata/integration/setup.yaml rename to cmd/crossplane/tree/testdata/integration/setup.yaml diff --git a/cmd/crossplane/trace/testdata/integration/xr.yaml b/cmd/crossplane/tree/testdata/integration/xr.yaml similarity index 100% rename from cmd/crossplane/trace/testdata/integration/xr.yaml rename to cmd/crossplane/tree/testdata/integration/xr.yaml diff --git a/cmd/crossplane/trace/trace.go b/cmd/crossplane/tree/trace.go similarity index 86% rename from cmd/crossplane/trace/trace.go rename to cmd/crossplane/tree/trace.go index c8cb2ed8..a10469d7 100644 --- a/cmd/crossplane/trace/trace.go +++ b/cmd/crossplane/tree/trace.go @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package trace contains the trace command. -package trace +// Package tree contains commands that operate on a Crossplane resource +// and its tree of children: trace, suspend, resume and reconcile. +package tree import ( "context" + "io" "strings" "github.com/alecthomas/kong" @@ -34,7 +36,7 @@ import ( "github.com/crossplane/crossplane/apis/v2/pkg" "github.com/crossplane/cli/v2/cmd/crossplane/internal" - "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" "github.com/crossplane/cli/v2/internal/kube" "github.com/crossplane/cli/v2/pkg/resource" "github.com/crossplane/cli/v2/pkg/resource/xpkg" @@ -60,10 +62,12 @@ const ( errNameDoubled = "name provided twice, must be provided separately 'TYPE[.VERSION][.GROUP] [NAME]' or in the 'TYPE[.VERSION][.GROUP][/NAME]' format" errInvalidResource = "invalid resource, must be provided in the 'TYPE[.VERSION][.GROUP][/NAME]' format" errInvalidResourceAndName = "invalid resource and name" + errFmtResourcePaused = "cannot reconcile paused resource: kind=%s name=%s namespace=%s (resume it first with 'crossplane resource resume')" + errFmtUpdateResource = "cannot update resource: kind=%s name=%s namespace=%s (resources processed before it may already have been updated; rerun the command to retry)" ) -// Cmd builds the trace tree for a Crossplane resource. -type Cmd struct { +// TraceCmd builds the trace tree for a Crossplane resource. +type TraceCmd struct { Resource string `arg:"" help:"Kind of the Crossplane resource, accepts the 'TYPE[.VERSION][.GROUP][/NAME]' format." predictor:"k8s_resource"` Name string `arg:"" help:"Name of the Crossplane resource, if not passed as part of the resource." optional:"" predictor:"k8s_resource_name"` @@ -82,11 +86,11 @@ type Cmd struct { } // Help returns help message for the trace command. -func (c *Cmd) Help() string { +func (c *TraceCmd) Help() string { return helpDetail } -func (c *Cmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, client.WithWatch, meta.RESTMapper, error) { +func (c *TraceCmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, client.WithWatch, meta.RESTMapper, error) { clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{CurrentContext: c.Context}, @@ -136,7 +140,7 @@ func (c *Cmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, cl } // Run runs the trace command. -func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { +func (c *TraceCmd) Run(k *kong.Context, logger logging.Logger) error { ctx := context.Background() logger = logger.WithValues("Resource", c.Resource, "Name", c.Name) @@ -230,31 +234,10 @@ func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { 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 - } - - // Print a single resource - err = p.Print(k.Stdout, resourceList.Items[0]) - if err != nil { - return errors.Wrap(err, errCliOutput) - } - - return nil + return printResources(k.Stdout, p, resourceList, shouldPrintAsList, c.Watch) } -func (c *Cmd) getResourceAndName() (string, string, error) { +func (c *TraceCmd) getResourceAndName() (string, string, error) { // If no resource was provided, error out (should never happen as it's // required by Kong) if c.Resource == "" { @@ -284,7 +267,7 @@ func (c *Cmd) getResourceAndName() (string, string, error) { return "", "", errors.New(errInvalidResource) } -func (c *Cmd) getResourceTree(ctx context.Context, root *resource.Resource, mapping *meta.RESTMapping, client client.Client, logger logging.Logger) (*resource.Resource, error) { +func (c *TraceCmd) getResourceTree(ctx context.Context, root *resource.Resource, mapping *meta.RESTMapping, client client.Client, logger logging.Logger) (*resource.Resource, error) { var treeClient resource.TreeClient var err error switch { @@ -311,3 +294,30 @@ func (c *Cmd) getResourceTree(ctx context.Context, root *resource.Resource, mapp return treeClient.GetResourceTree(ctx, root) } + +// printResources prints the supplied resources to w. If asList is true all +// resources are printed as a list, otherwise only the first one is printed. +func printResources(w io.Writer, p printer.Printer, resourceList *resource.ResourceList, asList, watch bool) error { + if asList { + if err := p.PrintList(w, resourceList); err != nil { + return errors.Wrap(err, errCliOutput) + } + // Warn if watch mode was requested with multiple resources + if watch { + if _, err := w.Write([]byte("error: you may only watch a single resource at a time\n")); err != nil { + return errors.Wrap(err, errCliOutput) + } + } + return nil + } + + if len(resourceList.Items) == 0 { + return nil + } + + if err := p.Print(w, resourceList.Items[0]); err != nil { + return errors.Wrap(err, errCliOutput) + } + + return nil +} diff --git a/cmd/crossplane/trace/trace_integration_test.go b/cmd/crossplane/tree/trace_integration_test.go similarity index 99% rename from cmd/crossplane/trace/trace_integration_test.go rename to cmd/crossplane/tree/trace_integration_test.go index 7b90bb45..90ee3b72 100644 --- a/cmd/crossplane/trace/trace_integration_test.go +++ b/cmd/crossplane/tree/trace_integration_test.go @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package trace +package tree import ( "bytes" @@ -61,7 +61,7 @@ func TestTraceIntegration(t *testing.T) { // trace loads its kubeconfig the way kubectl does. t.Setenv("KUBECONFIG", kubeconfig) - var c Cmd + var c TraceCmd parser, err := kong.New(&c) if err != nil { diff --git a/cmd/crossplane/tree/trace_test.go b/cmd/crossplane/tree/trace_test.go new file mode 100644 index 00000000..cd1a6918 --- /dev/null +++ b/cmd/crossplane/tree/trace_test.go @@ -0,0 +1,234 @@ +package tree + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" + "github.com/crossplane/cli/v2/pkg/resource" +) + +func TestCmd_getResourceAndName(t *testing.T) { + type args struct { + Resource string + Name string + } + + type want struct { + resource string + name string + err error + } + + tests := map[string]struct { + reason string + fields args + want want + }{ + "Splitted": { + reason: "Should return the resource and name if both are provided", + fields: args{ + Resource: "resource", + Name: "name", + }, + want: want{ + resource: "resource", + name: "name", + err: nil, + }, + }, + "Empty": { + // should never happen, resource is required by kong + reason: "Should return an error if no resource is provided", + fields: args{ + Resource: "", + Name: "", + }, + want: want{ + err: errors.New(errInvalidResource), + }, + }, + "Combined": { + reason: "Should return the resource and name if both are provided combined as resource", + fields: args{ + Resource: "resource/name", + Name: "", + }, + want: want{ + resource: "resource", + name: "name", + }, + }, + "MoreSlashes": { + reason: "Should return an error if the resource contains more than one slashes", + fields: args{ + Resource: "resource/name/other", + Name: "", + }, + want: want{ + err: errors.New(errInvalidResource), + }, + }, + "BothAndCombined": { + reason: "Should return an error if a name is provided both in the resource and separately", + fields: args{ + Resource: "resource/name", + Name: "name", + }, + want: want{ + err: errors.New(errNameDoubled), + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &TraceCmd{ + Resource: tt.fields.Resource, + Name: tt.fields.Name, + } + + gotResource, gotName, err := c.getResourceAndName() + if diff := cmp.Diff(tt.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("TraceCmd.getResourceAndName() error = %v, wantErr %v", err, tt.want.err) + } + + if diff := cmp.Diff(tt.want.resource, gotResource); diff != "" { + t.Errorf("TraceCmd.getResourceAndName() resource = %v, want %v", gotResource, tt.want.resource) + } + + if diff := cmp.Diff(tt.want.name, gotName); diff != "" { + t.Errorf("TraceCmd.getResourceAndName() name = %v, want %v", gotName, tt.want.name) + } + }) + } +} + +// fakePrinter writes the names of the resources it is asked to print. +type fakePrinter struct { + err error +} + +func (p *fakePrinter) Print(w io.Writer, r *resource.Resource) error { + if p.err != nil { + return p.err + } + _, err := fmt.Fprintf(w, "single: %s\n", r.Unstructured.GetName()) + return err +} + +func (p *fakePrinter) PrintList(w io.Writer, l *resource.ResourceList) error { + if p.err != nil { + return p.err + } + for _, r := range l.Items { + if _, err := fmt.Fprintf(w, "list: %s\n", r.Unstructured.GetName()); err != nil { + return err + } + } + return nil +} + +func TestPrintResources(t *testing.T) { + errBoom := errors.New("boom") + + type args struct { + printer printer.Printer + items []*resource.Resource + asList bool + watch bool + } + + type want struct { + output string + err error + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "Named": { + reason: "Should print the single named resource", + args: args{ + printer: &fakePrinter{}, + items: []*resource.Resource{newTestResource("root", nil)}, + }, + want: want{ + output: "single: root\n", + }, + }, + "NamedNoItems": { + reason: "Should print nothing if there is no named resource", + args: args{ + printer: &fakePrinter{}, + }, + }, + "List": { + reason: "Should print all resources as a list", + args: args{ + printer: &fakePrinter{}, + items: []*resource.Resource{newTestResource("a", nil), newTestResource("b", nil)}, + asList: true, + }, + want: want{ + output: "list: a\nlist: b\n", + }, + }, + "ListWatch": { + reason: "Should print the list and an error message if watch was requested for a list", + args: args{ + printer: &fakePrinter{}, + items: []*resource.Resource{newTestResource("a", nil)}, + asList: true, + watch: true, + }, + want: want{ + output: "list: a\nerror: you may only watch a single resource at a time\n", + }, + }, + "NamedPrintError": { + reason: "Should return an error if printing the named resource fails", + args: args{ + printer: &fakePrinter{err: errBoom}, + items: []*resource.Resource{newTestResource("root", nil)}, + }, + want: want{ + err: errors.Wrap(errBoom, errCliOutput), + }, + }, + "ListPrintError": { + reason: "Should return an error if printing the list fails", + args: args{ + printer: &fakePrinter{err: errBoom}, + items: []*resource.Resource{newTestResource("a", nil)}, + asList: true, + }, + want: want{ + err: errors.Wrap(errBoom, errCliOutput), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + + err := printResources(&buf, tc.args.printer, &resource.ResourceList{Items: tc.args.items}, tc.args.asList, tc.args.watch) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nprintResources(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.output, buf.String()); diff != "" { + t.Errorf("\n%s\nprintResources(...): -want output, +got output:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/trace/watch.go b/cmd/crossplane/tree/watch.go similarity index 90% rename from cmd/crossplane/trace/watch.go rename to cmd/crossplane/tree/watch.go index a49e0ff6..0a2bf132 100644 --- a/cmd/crossplane/trace/watch.go +++ b/cmd/crossplane/tree/watch.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package trace +package tree import ( "bytes" @@ -34,7 +34,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" + "github.com/crossplane/cli/v2/cmd/crossplane/tree/internal/printer" "github.com/crossplane/cli/v2/pkg/resource" ) @@ -100,7 +100,7 @@ func renderTreeToString(p printer.Printer, tree *resource.Resource) (string, err } // watchResourceTree watches the resource tree until it's deleted. -func (c *Cmd) watchResourceTree(ctx context.Context, k *kong.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer) error { +func (c *TraceCmd) watchResourceTree(ctx context.Context, k *kong.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer) error { // Create a watch for the specific resource opts := &client.ListOptions{ Namespace: root.Unstructured.GetNamespace(), @@ -136,7 +136,7 @@ func (c *Cmd) watchResourceTree(ctx context.Context, k *kong.Context, logger log } // watchProducer runs the watch loop and sends updates to Bubble Tea. -func (c *Cmd) watchProducer(ctx context.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer, prog *tea.Program, w watch.Interface) { +func (c *TraceCmd) watchProducer(ctx context.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer, prog *tea.Program, w watch.Interface) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -220,7 +220,7 @@ func (c *Cmd) watchProducer(ctx context.Context, logger logging.Logger, kClient } // handleProducerError handles errors from the watch producer. -func (c *Cmd) handleProducerError(prog *tea.Program, err error) { +func (c *TraceCmd) handleProducerError(prog *tea.Program, err error) { if apierrors.IsNotFound(err) { prog.Send(treeQuitMsg{}) return