From 05d8e017dfbb23d10e6835b2c7f6cd418609df29 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 10 Nov 2026 19:01:54 -0500 Subject: [PATCH 1/3] Expand trace integration tests to a table of cases Cover the wide, json, yaml and dot printers, the resource argument forms, --show-connection-secrets, --concurrency=1, a cluster-scoped XR, a nonexistent resource, and package traces with their revisions, dependencies and runtime configs. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- .../integration/composed-standalone.yaml | 19 + .../packages-current-revision.yaml | 19 + .../trace/testdata/integration/packages.yaml | 145 +++++++ .../trace/testdata/integration/setup.yaml | 15 + .../testdata/integration/xr-standalone.yaml | 9 + .../trace/testdata/integration/xr.yaml | 3 + .../trace/trace_integration_test.go | 356 +++++++++++++++++- 7 files changed, 547 insertions(+), 19 deletions(-) create mode 100644 cmd/crossplane/trace/testdata/integration/composed-standalone.yaml create mode 100644 cmd/crossplane/trace/testdata/integration/packages-current-revision.yaml create mode 100644 cmd/crossplane/trace/testdata/integration/packages.yaml create mode 100644 cmd/crossplane/trace/testdata/integration/xr-standalone.yaml diff --git a/cmd/crossplane/trace/testdata/integration/composed-standalone.yaml b/cmd/crossplane/trace/testdata/integration/composed-standalone.yaml new file mode 100644 index 00000000..f09d0180 --- /dev/null +++ b/cmd/crossplane/trace/testdata/integration/composed-standalone.yaml @@ -0,0 +1,19 @@ +apiVersion: nop.example.org/v1alpha1 +kind: NopResource +metadata: + annotations: + crossplane.io/composition-resource-name: only + name: standalone-xr-only +spec: + forProvider: + configData: standalone-value +status: + conditions: + - type: Synced + status: "True" + reason: ReconcileSuccess + lastTransitionTime: "2026-01-01T00:00:00Z" + - type: Ready + status: "True" + reason: Available + lastTransitionTime: "2026-01-01T00:00:00Z" diff --git a/cmd/crossplane/trace/testdata/integration/packages-current-revision.yaml b/cmd/crossplane/trace/testdata/integration/packages-current-revision.yaml new file mode 100644 index 00000000..3471a03e --- /dev/null +++ b/cmd/crossplane/trace/testdata/integration/packages-current-revision.yaml @@ -0,0 +1,19 @@ +# The Configuration's current revision. The test creates it a second after +# packages.yaml's inactive one: revisions sort by creationTimestamp, which has +# one-second resolution. +apiVersion: pkg.crossplane.io/v1 +kind: ConfigurationRevision +metadata: + name: platform-2222 + labels: + pkg.crossplane.io/package: platform +spec: + desiredState: Active + image: xpkg.example.org/acme/platform:v1.0.0 + revision: 2 +status: + conditions: + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" diff --git a/cmd/crossplane/trace/testdata/integration/packages.yaml b/cmd/crossplane/trace/testdata/integration/packages.yaml new file mode 100644 index 00000000..0b0baafd --- /dev/null +++ b/cmd/crossplane/trace/testdata/integration/packages.yaml @@ -0,0 +1,145 @@ +# A Configuration depending on a Provider and a Function, the Provider also +# depending on the Function, with their revisions and the Lock, as the package +# manager leaves them. The API server drops status on create, so the test +# writes each object's status, and points each revision's controller reference +# at its package, after Start. +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: default +--- +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: platform +spec: + package: xpkg.example.org/acme/platform:v1.0.0 +status: + currentRevision: platform-2222 + conditions: + - type: Installed + status: "True" + reason: ActivePackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-nop +spec: + package: xpkg.example.org/acme/provider-nop:v0.5.0 +status: + currentRevision: provider-nop-aaaa + conditions: + - type: Installed + status: "True" + reason: ActivePackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" + - type: Healthy + status: "False" + reason: UnhealthyPackageRevision + message: runtime is not ready + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-dummy +spec: + package: xpkg.example.org/acme/function-dummy:v0.3.0 +status: + currentRevision: function-dummy-bbbb + conditions: + - type: Installed + status: "True" + reason: ActivePackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1 +kind: ConfigurationRevision +metadata: + name: platform-1111 + labels: + pkg.crossplane.io/package: platform +spec: + desiredState: Inactive + image: xpkg.example.org/acme/platform:v0.9.0 + revision: 1 +status: + conditions: + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1 +kind: ProviderRevision +metadata: + name: provider-nop-aaaa + labels: + pkg.crossplane.io/package: provider-nop +spec: + desiredState: Active + image: xpkg.example.org/acme/provider-nop:v0.5.0 + revision: 1 +status: + conditions: + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1 +kind: FunctionRevision +metadata: + name: function-dummy-bbbb + labels: + pkg.crossplane.io/package: function-dummy +spec: + desiredState: Active + image: xpkg.example.org/acme/function-dummy:v0.3.0 + revision: 1 +status: + conditions: + - type: Healthy + status: "True" + reason: HealthyPackageRevision + lastTransitionTime: "2026-01-01T00:00:00Z" +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Lock +metadata: + name: lock +packages: + - name: platform-2222 + type: Configuration + source: xpkg.example.org/acme/platform + version: v1.0.0 + dependencies: + - package: xpkg.example.org/acme/provider-nop + type: Provider + constraints: ">=v0.5.0" + - package: xpkg.example.org/acme/function-dummy + type: Function + constraints: ">=v0.3.0" + - name: provider-nop-aaaa + type: Provider + source: xpkg.example.org/acme/provider-nop + version: v0.5.0 + dependencies: + - package: xpkg.example.org/acme/function-dummy + type: Function + constraints: ">=v0.3.0" + - name: function-dummy-bbbb + type: Function + source: xpkg.example.org/acme/function-dummy + version: v0.3.0 + dependencies: [] diff --git a/cmd/crossplane/trace/testdata/integration/setup.yaml b/cmd/crossplane/trace/testdata/integration/setup.yaml index 64841d32..1762baf3 100644 --- a/cmd/crossplane/trace/testdata/integration/setup.yaml +++ b/cmd/crossplane/trace/testdata/integration/setup.yaml @@ -52,9 +52,24 @@ metadata: namespace: test-namespace spec: coolField: claim-value + writeConnectionSecretToRef: + name: test-claim-conn compositionRef: name: xnops.trace.example.org resourceRef: apiVersion: trace.example.org/v1alpha1 kind: XNop name: test-claim-xr +--- +# The connection secrets the claim and its XR write. +apiVersion: v1 +kind: Secret +metadata: + name: test-claim-conn + namespace: test-namespace +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-claim-xr-conn + namespace: test-namespace diff --git a/cmd/crossplane/trace/testdata/integration/xr-standalone.yaml b/cmd/crossplane/trace/testdata/integration/xr-standalone.yaml new file mode 100644 index 00000000..900e83c8 --- /dev/null +++ b/cmd/crossplane/trace/testdata/integration/xr-standalone.yaml @@ -0,0 +1,9 @@ +# A cluster-scoped XR no claim binds. +apiVersion: trace.example.org/v1alpha1 +kind: XNop +metadata: + name: standalone-xr +spec: + coolField: standalone-value + compositionRef: + name: xnops.trace.example.org diff --git a/cmd/crossplane/trace/testdata/integration/xr.yaml b/cmd/crossplane/trace/testdata/integration/xr.yaml index ef80c620..edf68593 100644 --- a/cmd/crossplane/trace/testdata/integration/xr.yaml +++ b/cmd/crossplane/trace/testdata/integration/xr.yaml @@ -11,5 +11,8 @@ spec: name: test-claim namespace: test-namespace coolField: claim-value + writeConnectionSecretToRef: + name: test-claim-xr-conn + namespace: test-namespace compositionRef: name: xnops.trace.example.org diff --git a/cmd/crossplane/trace/trace_integration_test.go b/cmd/crossplane/trace/trace_integration_test.go index 7b90bb45..5f562ddf 100644 --- a/cmd/crossplane/trace/trace_integration_test.go +++ b/cmd/crossplane/trace/trace_integration_test.go @@ -25,12 +25,19 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/internal/testutils/integration" ) @@ -39,20 +46,29 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// TestTraceIntegration traces a claim through its XR to the resources the XR composes, read from a real API -// server, and checks the tree the default printer draws. +// TestTraceIntegration traces resources read from a real API server: a claim through its XR to the resources the +// XR composes, a cluster-scoped XR no claim binds, and a Configuration with its revisions and dependencies. Each +// case is a trace command line and what it prints, or the error it returns. func TestTraceIntegration(t *testing.T) { cluster := integration.Start(t, integration.Setup{ CRDDirs: []string{"testdata/integration/crds"}, - SetupFiles: []string{"testdata/integration/setup.yaml"}, + SetupFiles: []string{"testdata/integration/setup.yaml", "testdata/integration/packages.yaml"}, CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes("testdata/integration/xr.yaml", "testdata/integration/composed-ready.yaml", "testdata/integration/composed-failing.yaml"), + integration.Composes("testdata/integration/xr-standalone.yaml", + "testdata/integration/composed-standalone.yaml"), }, XRDAPIVersion: integration.XRDV1, }) + // Revisions sort by creationTimestamp, which has one-second resolution, so the current revision is created a + // second after the inactive one to make their order stable. + time.Sleep(time.Second) + createFromFile(t, cluster, "testdata/integration/packages-current-revision.yaml") + setPackageStatus(t, cluster, "testdata/integration/packages.yaml", "testdata/integration/packages-current-revision.yaml") + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") if err := os.WriteFile(kubeconfig, cluster.Kubeconfig, 0o600); err != nil { t.Fatalf("cannot write kubeconfig: %v", err) @@ -61,6 +77,222 @@ func TestTraceIntegration(t *testing.T) { // trace loads its kubeconfig the way kubectl does. t.Setenv("KUBECONFIG", kubeconfig) + // Children sort by apiVersion, kind and name, so failing comes before ready. No controller runs under envtest, + // so the claim and XR have no conditions. + //nolint:dupword // Columns of the table repeat values. + claimTree := ` +NAME SYNCED READY STATUS +NopClaim/test-claim (test-namespace) - - +└─ XNop/test-claim-xr - - + ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource + └─ NopResource/test-claim-xr-ready True True Available +` + + claimNodes := &node{Kind: "NopClaim", Name: "test-claim", Children: []*node{ + {Kind: "XNop", Name: "test-claim-xr", Children: []*node{ + {Kind: "NopResource", Name: "test-claim-xr-failing"}, + {Kind: "NopResource", Name: "test-claim-xr-ready"}, + }}, + }} + + type want struct { + // stdout is the whole output, compared with trimLines. + stdout string + // tree is the kind, name and children of each resource in json or yaml output. + tree *node + // err is a substring of the error Run returns. + err string + } + + cases := map[string]struct { + reason string + args []string + want want + }{ + "Default": { + reason: "The default printer should draw the claim, its XR and the XR's composed resources as a tree.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace"}, + want: want{stdout: claimTree}, + }, + "ConcurrencyOne": { + reason: "Loading the tree one resource at a time should draw the same tree.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--concurrency=1"}, + want: want{stdout: claimTree}, + }, + "TypeVersionGroupSlashName": { + reason: "A TYPE.VERSION.GROUP/NAME resource should trace the same claim.", + args: []string{"nopclaims.v1alpha1.trace.example.org/test-claim", "-n", "test-namespace"}, + want: want{stdout: claimTree}, + }, + "KindSlashName": { + reason: "A kind, rather than a plural, with the name after a slash should trace the same claim.", + args: []string{"NopClaim/test-claim", "-n", "test-namespace"}, + want: want{stdout: claimTree}, + }, + "PluralSeparateName": { + reason: "A plural resource with a separate name argument should trace the same claim.", + args: []string{"nopclaims", "test-claim", "-n", "test-namespace"}, + want: want{stdout: claimTree}, + }, + "Wide": { + reason: "The wide printer should add each composed resource's composition resource name.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "wide"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME RESOURCE SYNCED READY STATUS +NopClaim/test-claim (test-namespace) - - +└─ XNop/test-claim-xr - - + ├─ NopResource/test-claim-xr-failing failing False - ReconcileError: cannot observe external resource + └─ NopResource/test-claim-xr-ready ready True True Available +`}, + }, + "JSON": { + reason: "The json printer should print the same tree.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "json"}, + want: want{tree: claimNodes}, + }, + "YAML": { + reason: "The yaml printer should print the same tree.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "yaml"}, + want: want{tree: claimNodes}, + }, + "Dot": { + reason: "The dot printer should draw a node per resource, and an edge from each to its parent.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "dot"}, + want: want{stdout: dotClaimTree}, + }, + "ShowConnectionSecrets": { + reason: "With --show-connection-secrets the claim's and the XR's connection secrets should be children.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--show-connection-secrets"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME SYNCED READY STATUS +NopClaim/test-claim (test-namespace) - - +├─ XNop/test-claim-xr - - +│ ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource +│ ├─ NopResource/test-claim-xr-ready True True Available +│ └─ Secret/test-claim-xr-conn (test-namespace) - - +└─ Secret/test-claim-conn (test-namespace) - - +`}, + }, + "ClusterScopedXR": { + reason: "An XR no claim binds should be traced with no namespace.", + args: []string{"xnop", "standalone-xr"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME SYNCED READY STATUS +XNop/standalone-xr - - +└─ NopResource/standalone-xr-only True True Available +`}, + }, + "Nonexistent": { + reason: "A resource that does not exist should be reported as such.", + args: []string{"nopclaim", "missing", "--namespace=test-namespace"}, + want: want{err: `cannot get requested resource: kind=NopClaim name=missing namespace=test-namespace ` + + `(verify context, namespace, and that the resource exists): nopclaims.trace.example.org "missing" not found`}, + }, + "PackageDefaults": { + reason: "A Configuration should show its active revision and each dependency once, by default.", + args: []string{"configuration", "platform"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/platform v1.0.0 True True - HealthyPackageRevision +├─ ConfigurationRevision/platform-2222 v1.0.0 - True Active HealthyPackageRevision +├─ Provider/provider-nop v0.5.0 True False - UnhealthyPackageRevision: runtime is not ready +│ └─ ProviderRevision/provider-nop-aaaa v0.5.0 - True Active HealthyPackageRevision +└─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision + └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision +`}, + }, + "PackageAllRevisionsAllDependencies": { + reason: "With all revisions and all dependencies, the inactive revision and the Provider's own dependency on the Function should show too.", + args: []string{"configuration", "platform", "--show-package-revisions=all", "--show-package-dependencies=all"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/platform v1.0.0 True True - HealthyPackageRevision +├─ ConfigurationRevision/platform-1111 v0.9.0 - True Inactive HealthyPackageRevision +├─ ConfigurationRevision/platform-2222 v1.0.0 - True Active HealthyPackageRevision +├─ Provider/provider-nop v0.5.0 True False - UnhealthyPackageRevision: runtime is not ready +│ ├─ ProviderRevision/provider-nop-aaaa v0.5.0 - True Active HealthyPackageRevision +│ └─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision +│ └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision +└─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision + └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision +`}, + }, + "PackageNoRevisionsNoDependencies": { + reason: "With no revisions and no dependencies, only the Configuration should show.", + args: []string{"configuration", "platform", "--show-package-revisions=none", "--show-package-dependencies=none"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/platform v1.0.0 True True - HealthyPackageRevision +`}, + }, + "PackageRuntimeConfigs": { + reason: "With --show-package-runtime-configs the Function should show the runtime config it references.", + args: []string{"function", "function-dummy", "--show-package-runtime-configs"}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME VERSION INSTALLED HEALTHY STATE STATUS +Function/function-dummy v0.3.0 True True - HealthyPackageRevision +├─ DeploymentRuntimeConfig/default - - - +└─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision +`}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + stdout, err := runTrace(t, tc.args) + + if tc.want.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.want.err) { + t.Fatalf("\n%s\nRun(): want error containing %q, got %v", tc.reason, tc.want.err, err) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nRun(): %v", tc.reason, err) + } + + if tc.want.tree != nil { + if diff := cmp.Diff(tc.want.tree, parseTree(t, tc.args, stdout)); diff != "" { + t.Errorf("\n%s\ntrace tree: -want, +got:\n%s", tc.reason, diff) + } + + return + } + + if diff := cmp.Diff(trimLines(tc.want.stdout), trimLines(stdout)); diff != "" { + t.Errorf("\n%s\ntrace output: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// dotClaimTree is the dot printer's graph of the claim: nodes are numbered breadth first. +const dotClaimTree = `graph { + + n1[label="Name: NopClaim/test-claim\nApiVersion: trace.example.org/v1alpha1\nNamespace: test-namespace\nReady: \nSynced: \n",penwidth="2"]; + n2[label="Name: XNop/test-claim-xr\nApiVersion: trace.example.org/v1alpha1\nReady: \nSynced: \n",penwidth="2"]; + n3[label="Name: NopResource/test-claim-xr-failing\nApiVersion: nop.example.org/v1alpha1\nReady: \nSynced: False\n",penwidth="2"]; + n4[label="Name: NopResource/test-claim-xr-ready\nApiVersion: nop.example.org/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n1--n2; + n2--n3; + n2--n4; + +} +` + +// runTrace parses args as the trace command's and runs it, returning what it prints. +func runTrace(t *testing.T, args []string) (string, error) { + t.Helper() + var c Cmd parser, err := kong.New(&c) @@ -68,31 +300,117 @@ func TestTraceIntegration(t *testing.T) { t.Fatalf("kong.New(): %v", err) } - kongCtx, err := parser.Parse([]string{"nopclaim", "test-claim", "--namespace=test-namespace"}) + kongCtx, err := parser.Parse(args) if err != nil { - t.Fatalf("Parse(): %v", err) + t.Fatalf("Parse(%v): %v", args, err) } var stdout bytes.Buffer kongCtx.Stdout = &stdout - if err := c.Run(kongCtx, logging.NewNopLogger()); err != nil { - t.Fatalf("Run(): %v", err) + err = c.Run(kongCtx, logging.NewNopLogger()) + + return stdout.String(), err +} + +// node is a resource's kind and name in a trace tree, with its children's. +type node struct { + Kind string + Name string + Children []*node +} + +// parseTree reads the tree trace printed as json or yaml; sigs.k8s.io/yaml reads both. +func parseTree(t *testing.T, args []string, out string) *node { + t.Helper() + + type printed struct { + Object map[string]any `json:"object"` + Children []*printed `json:"children"` } - // Children sort by apiVersion, kind and name, so failing comes before ready. No controller runs under envtest, - // so the claim and XR have no conditions. - //nolint:dupword // Columns of the table repeat values. - want := ` -NAME SYNCED READY STATUS -NopClaim/test-claim (test-namespace) - - -└─ XNop/test-claim-xr - - - ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource - └─ NopResource/test-claim-xr-ready True True Available -` + var p printed + if err := yaml.Unmarshal([]byte(out), &p); err != nil { + t.Fatalf("cannot unmarshal the output of trace %v: %v", args, err) + } + + var toNode func(p *printed) *node + toNode = func(p *printed) *node { + o := &un.Unstructured{Object: p.Object} + n := &node{Kind: o.GetKind(), Name: o.GetName()} + + for _, c := range p.Children { + n.Children = append(n.Children, toNode(c)) + } + + return n + } + + return toNode(&p) +} + +// createFromFile creates the resources in path. +func createFromFile(t *testing.T, cluster *integration.Cluster, path string) { + t.Helper() + + objs, err := integration.ReadResourcesFromFile(path) + if err != nil { + t.Fatal(err) + } + + for _, o := range objs { + if err := cluster.Client.Create(cluster.Context, o); err != nil { + t.Fatalf("cannot create %s %s: %v", o.GetKind(), o.GetName(), err) + } + } +} + +// setPackageStatus writes the status each package and package revision in paths declares, which the API server +// drops on create, and gives each revision a controller reference to the package its parent package label names, +// as the package manager would. +func setPackageStatus(t *testing.T, cluster *integration.Cluster, paths ...string) { + t.Helper() + + for _, path := range paths { + objs, err := integration.ReadResourcesFromFile(path) + if err != nil { + t.Fatal(err) + } + + for _, want := range objs { + status, ok := want.Object["status"] + if !ok { + continue + } + + got := &un.Unstructured{} + got.SetGroupVersionKind(want.GroupVersionKind()) + + if err := cluster.Client.Get(cluster.Context, client.ObjectKeyFromObject(want), got); err != nil { + t.Fatalf("cannot get %s %s: %v", want.GetKind(), want.GetName(), err) + } + + if parent := want.GetLabels()[pkgv1.LabelParentPackage]; parent != "" { + owner := &un.Unstructured{} + owner.SetAPIVersion(want.GetAPIVersion()) + owner.SetKind(strings.TrimSuffix(want.GetKind(), "Revision")) + + if err := cluster.Client.Get(cluster.Context, client.ObjectKey{Name: parent}, owner); err != nil { + t.Fatalf("cannot get the package of %s %s: %v", want.GetKind(), want.GetName(), err) + } + + got.SetOwnerReferences([]metav1.OwnerReference{*metav1.NewControllerRef(owner, owner.GroupVersionKind())}) + + if err := cluster.Client.Update(cluster.Context, got); err != nil { + t.Fatalf("cannot set the owner of %s %s: %v", want.GetKind(), want.GetName(), err) + } + } - if diff := cmp.Diff(trimLines(want), trimLines(stdout.String())); diff != "" { - t.Errorf("trace output: -want, +got:\n%s", diff) + got.Object["status"] = status + if err := cluster.Client.Status().Update(cluster.Context, got); err != nil { + t.Fatalf("cannot set the status of %s %s: %v", want.GetKind(), want.GetName(), err) + } + } } } From ec5162419e1fa6a79ad200e2024d25ba970c47a4 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 11:04:14 -0500 Subject: [PATCH 2/3] Assert trace integration cases on decoded JSON trees Compare most cases on the tree trace prints as json, with each resource's conditions, package image and revision state, keeping one full-text check each for the default, wide and dot printers and one yaml check. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- .../trace/trace_integration_test.go | 288 ++++++++++-------- 1 file changed, 157 insertions(+), 131 deletions(-) diff --git a/cmd/crossplane/trace/trace_integration_test.go b/cmd/crossplane/trace/trace_integration_test.go index 5f562ddf..9236918a 100644 --- a/cmd/crossplane/trace/trace_integration_test.go +++ b/cmd/crossplane/trace/trace_integration_test.go @@ -77,28 +77,47 @@ func TestTraceIntegration(t *testing.T) { // trace loads its kubeconfig the way kubectl does. t.Setenv("KUBECONFIG", kubeconfig) - // Children sort by apiVersion, kind and name, so failing comes before ready. No controller runs under envtest, - // so the claim and XR have no conditions. - //nolint:dupword // Columns of the table repeat values. - claimTree := ` -NAME SYNCED READY STATUS -NopClaim/test-claim (test-namespace) - - -└─ XNop/test-claim-xr - - - ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource - └─ NopResource/test-claim-xr-ready True True Available -` - - claimNodes := &node{Kind: "NopClaim", Name: "test-claim", Children: []*node{ - {Kind: "XNop", Name: "test-claim-xr", Children: []*node{ - {Kind: "NopResource", Name: "test-claim-xr-failing"}, - {Kind: "NopResource", Name: "test-claim-xr-ready"}, - }}, + // The resources trace finds, as parseTree reads them from its json or yaml output. No controller runs under + // envtest, so the claim and XRs have no conditions. + failing := &node{ + Kind: "NopResource", Name: "test-claim-xr-failing", + Synced: "False ReconcileError: cannot observe external resource", + } + ready := &node{Kind: "NopResource", Name: "test-claim-xr-ready", Synced: "True ReconcileSuccess", Ready: "True Available"} + claimTree := &node{Kind: "NopClaim", Name: "test-claim", Namespace: "test-namespace", Children: []*node{ + {Kind: "XNop", Name: "test-claim-xr", Children: []*node{failing, ready}}, }} + healthy := "True HealthyPackageRevision" + installed := "True ActivePackageRevision" + pkg := func(kind, name, image string, children ...*node) *node { + return &node{Kind: kind, Name: name, Package: image, Installed: installed, Healthy: healthy, Children: children} + } + rev := func(kind, name, image, state string) *node { + return &node{Kind: kind, Name: name, Package: image, Healthy: healthy, State: state} + } + configuration := func(children ...*node) *node { + return pkg("Configuration", "platform", "xpkg.example.org/acme/platform:v1.0.0", children...) + } + platformInactive := rev("ConfigurationRevision", "platform-1111", "xpkg.example.org/acme/platform:v0.9.0", "Inactive") + platformActive := rev("ConfigurationRevision", "platform-2222", "xpkg.example.org/acme/platform:v1.0.0", "Active") + provider := func(children ...*node) *node { + p := pkg("Provider", "provider-nop", "xpkg.example.org/acme/provider-nop:v0.5.0", + append([]*node{rev("ProviderRevision", "provider-nop-aaaa", "xpkg.example.org/acme/provider-nop:v0.5.0", "Active")}, children...)...) + p.Healthy = "False UnhealthyPackageRevision: runtime is not ready" + + return p + } + function := func(children ...*node) *node { + return pkg("Function", "function-dummy", "xpkg.example.org/acme/function-dummy:v0.3.0", + append(children, rev("FunctionRevision", "function-dummy-bbbb", "xpkg.example.org/acme/function-dummy:v0.3.0", "Active"))...) + } + type want struct { - // stdout is the whole output, compared with trimLines. + // stdout is the whole output, compared with trimLines. Only the cases that check a printer's wiring to + // stdout set it. stdout string - // tree is the kind, name and children of each resource in json or yaml output. + // tree is the json or yaml output, as parseTree reads it. tree *node // err is a substring of the error Run returns. err string @@ -110,29 +129,16 @@ NopClaim/test-claim (test-namespace) - - want want }{ "Default": { - reason: "The default printer should draw the claim, its XR and the XR's composed resources as a tree.", + reason: "The default printer should draw the claim, its XR and the XR's composed resources as a tree on stdout.", args: []string{"nopclaim", "test-claim", "--namespace=test-namespace"}, - want: want{stdout: claimTree}, - }, - "ConcurrencyOne": { - reason: "Loading the tree one resource at a time should draw the same tree.", - args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--concurrency=1"}, - want: want{stdout: claimTree}, - }, - "TypeVersionGroupSlashName": { - reason: "A TYPE.VERSION.GROUP/NAME resource should trace the same claim.", - args: []string{"nopclaims.v1alpha1.trace.example.org/test-claim", "-n", "test-namespace"}, - want: want{stdout: claimTree}, - }, - "KindSlashName": { - reason: "A kind, rather than a plural, with the name after a slash should trace the same claim.", - args: []string{"NopClaim/test-claim", "-n", "test-namespace"}, - want: want{stdout: claimTree}, - }, - "PluralSeparateName": { - reason: "A plural resource with a separate name argument should trace the same claim.", - args: []string{"nopclaims", "test-claim", "-n", "test-namespace"}, - want: want{stdout: claimTree}, + //nolint:dupword // Columns of the table repeat values. + want: want{stdout: ` +NAME SYNCED READY STATUS +NopClaim/test-claim (test-namespace) - - +└─ XNop/test-claim-xr - - + ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource + └─ NopResource/test-claim-xr-ready True True Available +`}, }, "Wide": { reason: "The wide printer should add each composed resource's composition resource name.", @@ -146,44 +152,68 @@ NopClaim/test-claim (test-namespace) - - └─ NopResource/test-claim-xr-ready ready True True Available `}, }, - "JSON": { - reason: "The json printer should print the same tree.", - args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "json"}, - want: want{tree: claimNodes}, + "Dot": { + reason: "The dot printer should draw a node per resource, numbered breadth first, and an edge from each to its parent.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "dot"}, + want: want{stdout: `graph { + + n1[label="Name: NopClaim/test-claim\nApiVersion: trace.example.org/v1alpha1\nNamespace: test-namespace\nReady: \nSynced: \n",penwidth="2"]; + n2[label="Name: XNop/test-claim-xr\nApiVersion: trace.example.org/v1alpha1\nReady: \nSynced: \n",penwidth="2"]; + n3[label="Name: NopResource/test-claim-xr-failing\nApiVersion: nop.example.org/v1alpha1\nReady: \nSynced: False\n",penwidth="2"]; + n4[label="Name: NopResource/test-claim-xr-ready\nApiVersion: nop.example.org/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n1--n2; + n2--n3; + n2--n4; + +}`}, }, "YAML": { - reason: "The yaml printer should print the same tree.", + reason: "The yaml printer should print the claim's tree.", args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "yaml"}, - want: want{tree: claimNodes}, + want: want{tree: claimTree}, }, - "Dot": { - reason: "The dot printer should draw a node per resource, and an edge from each to its parent.", - args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "dot"}, - want: want{stdout: dotClaimTree}, + "JSON": { + reason: "The json printer should print the claim, its XR and the XR's composed resources with their conditions.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "-o", "json"}, + want: want{tree: claimTree}, + }, + "ConcurrencyOne": { + reason: "Loading the tree one resource at a time should find the same tree, in the same order.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--concurrency=1", "-o", "json"}, + want: want{tree: claimTree}, + }, + "TypeVersionGroupSlashName": { + reason: "A TYPE.VERSION.GROUP/NAME resource argument should resolve to the claim.", + args: []string{"nopclaims.v1alpha1.trace.example.org/test-claim", "-n", "test-namespace", "-o", "json"}, + want: want{tree: claimTree}, + }, + "KindSlashName": { + reason: "A kind, rather than a plural, with the name after a slash should resolve to the claim.", + args: []string{"NopClaim/test-claim", "-n", "test-namespace", "-o", "json"}, + want: want{tree: claimTree}, + }, + "PluralSeparateName": { + reason: "A plural resource argument with a separate name argument should resolve to the claim.", + args: []string{"nopclaims", "test-claim", "-n", "test-namespace", "-o", "json"}, + want: want{tree: claimTree}, }, "ShowConnectionSecrets": { - reason: "With --show-connection-secrets the claim's and the XR's connection secrets should be children.", - args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--show-connection-secrets"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME SYNCED READY STATUS -NopClaim/test-claim (test-namespace) - - -├─ XNop/test-claim-xr - - -│ ├─ NopResource/test-claim-xr-failing False - ReconcileError: cannot observe external resource -│ ├─ NopResource/test-claim-xr-ready True True Available -│ └─ Secret/test-claim-xr-conn (test-namespace) - - -└─ Secret/test-claim-conn (test-namespace) - - -`}, + reason: "With --show-connection-secrets the claim's and the XR's connection secrets should follow their other children.", + args: []string{"nopclaim", "test-claim", "--namespace=test-namespace", "--show-connection-secrets", "-o", "json"}, + want: want{tree: &node{Kind: "NopClaim", Name: "test-claim", Namespace: "test-namespace", Children: []*node{ + {Kind: "XNop", Name: "test-claim-xr", Children: []*node{ + failing, ready, + {Kind: "Secret", Name: "test-claim-xr-conn", Namespace: "test-namespace"}, + }}, + {Kind: "Secret", Name: "test-claim-conn", Namespace: "test-namespace"}, + }}}, }, "ClusterScopedXR": { - reason: "An XR no claim binds should be traced with no namespace.", - args: []string{"xnop", "standalone-xr"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME SYNCED READY STATUS -XNop/standalone-xr - - -└─ NopResource/standalone-xr-only True True Available -`}, + reason: "An XR no claim binds should be traced by name alone, with no namespace.", + args: []string{"xnop", "standalone-xr", "-o", "json"}, + want: want{tree: &node{Kind: "XNop", Name: "standalone-xr", Children: []*node{ + {Kind: "NopResource", Name: "standalone-xr-only", Synced: "True ReconcileSuccess", Ready: "True Available"}, + }}}, }, "Nonexistent": { reason: "A resource that does not exist should be reported as such.", @@ -192,55 +222,24 @@ XNop/standalone-xr - - `(verify context, namespace, and that the resource exists): nopclaims.trace.example.org "missing" not found`}, }, "PackageDefaults": { - reason: "A Configuration should show its active revision and each dependency once, by default.", - args: []string{"configuration", "platform"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME VERSION INSTALLED HEALTHY STATE STATUS -Configuration/platform v1.0.0 True True - HealthyPackageRevision -├─ ConfigurationRevision/platform-2222 v1.0.0 - True Active HealthyPackageRevision -├─ Provider/provider-nop v0.5.0 True False - UnhealthyPackageRevision: runtime is not ready -│ └─ ProviderRevision/provider-nop-aaaa v0.5.0 - True Active HealthyPackageRevision -└─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision - └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision -`}, + reason: "By default a Configuration should show only its active revision, and each dependency once, with the dependency's active revision.", + args: []string{"configuration", "platform", "-o", "json"}, + want: want{tree: configuration(platformActive, provider(), function())}, }, "PackageAllRevisionsAllDependencies": { - reason: "With all revisions and all dependencies, the inactive revision and the Provider's own dependency on the Function should show too.", - args: []string{"configuration", "platform", "--show-package-revisions=all", "--show-package-dependencies=all"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME VERSION INSTALLED HEALTHY STATE STATUS -Configuration/platform v1.0.0 True True - HealthyPackageRevision -├─ ConfigurationRevision/platform-1111 v0.9.0 - True Inactive HealthyPackageRevision -├─ ConfigurationRevision/platform-2222 v1.0.0 - True Active HealthyPackageRevision -├─ Provider/provider-nop v0.5.0 True False - UnhealthyPackageRevision: runtime is not ready -│ ├─ ProviderRevision/provider-nop-aaaa v0.5.0 - True Active HealthyPackageRevision -│ └─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision -│ └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision -└─ Function/function-dummy v0.3.0 True True - HealthyPackageRevision - └─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision -`}, + reason: "With all revisions and all dependencies, the inactive revision, oldest first, and the Provider's own dependency on the Function should show too.", + args: []string{"configuration", "platform", "--show-package-revisions=all", "--show-package-dependencies=all", "-o", "json"}, + want: want{tree: configuration(platformInactive, platformActive, provider(function()), function())}, }, "PackageNoRevisionsNoDependencies": { reason: "With no revisions and no dependencies, only the Configuration should show.", - args: []string{"configuration", "platform", "--show-package-revisions=none", "--show-package-dependencies=none"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME VERSION INSTALLED HEALTHY STATE STATUS -Configuration/platform v1.0.0 True True - HealthyPackageRevision -`}, + args: []string{"configuration", "platform", "--show-package-revisions=none", "--show-package-dependencies=none", "-o", "json"}, + want: want{tree: configuration()}, }, "PackageRuntimeConfigs": { - reason: "With --show-package-runtime-configs the Function should show the runtime config it references.", - args: []string{"function", "function-dummy", "--show-package-runtime-configs"}, - //nolint:dupword // Columns of the table repeat values. - want: want{stdout: ` -NAME VERSION INSTALLED HEALTHY STATE STATUS -Function/function-dummy v0.3.0 True True - HealthyPackageRevision -├─ DeploymentRuntimeConfig/default - - - -└─ FunctionRevision/function-dummy-bbbb v0.3.0 - True Active HealthyPackageRevision -`}, + reason: "With --show-package-runtime-configs the Function should show the runtime config it references before its revision.", + args: []string{"function", "function-dummy", "--show-package-runtime-configs", "-o", "json"}, + want: want{tree: function(&node{Kind: "DeploymentRuntimeConfig", Name: "default"})}, }, } @@ -275,20 +274,6 @@ Function/function-dummy v0.3.0 True True - } } -// dotClaimTree is the dot printer's graph of the claim: nodes are numbered breadth first. -const dotClaimTree = `graph { - - n1[label="Name: NopClaim/test-claim\nApiVersion: trace.example.org/v1alpha1\nNamespace: test-namespace\nReady: \nSynced: \n",penwidth="2"]; - n2[label="Name: XNop/test-claim-xr\nApiVersion: trace.example.org/v1alpha1\nReady: \nSynced: \n",penwidth="2"]; - n3[label="Name: NopResource/test-claim-xr-failing\nApiVersion: nop.example.org/v1alpha1\nReady: \nSynced: False\n",penwidth="2"]; - n4[label="Name: NopResource/test-claim-xr-ready\nApiVersion: nop.example.org/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; - n1--n2; - n2--n3; - n2--n4; - -} -` - // runTrace parses args as the trace command's and runs it, returning what it prints. func runTrace(t *testing.T, args []string) (string, error) { t.Helper() @@ -313,14 +298,28 @@ func runTrace(t *testing.T, args []string) (string, error) { return stdout.String(), err } -// node is a resource's kind and name in a trace tree, with its children's. +// node is a resource in a trace tree, with what trace reports of it, and its children's. Each condition is +// its status, then its reason and any message, as the default printer's columns and STATUS show them; a field the +// resource doesn't have is empty. type node struct { - Kind string - Name string + Kind string + Name string + Namespace string + Synced string + Ready string + Installed string + Healthy string + // Package is a package's spec.package or a package revision's spec.image. + Package string + // State is a package revision's spec.desiredState. + State string Children []*node } -// parseTree reads the tree trace printed as json or yaml; sigs.k8s.io/yaml reads both. +// parseTree reads the tree trace printed as json or yaml; sigs.k8s.io/yaml reads both. Children are left in the +// order trace printed them, which is deterministic and the text tree's: the XR client sorts each resource's +// children by apiVersion, kind and name, and the package client orders them runtime config, revisions oldest +// first, then dependencies in lock order. func parseTree(t *testing.T, args []string, out string) *node { t.Helper() @@ -337,7 +336,34 @@ func parseTree(t *testing.T, args []string, out string) *node { var toNode func(p *printed) *node toNode = func(p *printed) *node { o := &un.Unstructured{Object: p.Object} - n := &node{Kind: o.GetKind(), Name: o.GetName()} + n := &node{Kind: o.GetKind(), Name: o.GetName(), Namespace: o.GetNamespace()} + + conds, _, _ := un.NestedSlice(o.Object, "status", "conditions") + for _, c := range conds { + c, _ := c.(map[string]any) + v := fmt.Sprintf("%v %v", c["status"], c["reason"]) + if m, ok := c["message"].(string); ok && m != "" { + v += ": " + m + } + + switch c["type"] { + case "Synced": + n.Synced = v + case "Ready": + n.Ready = v + case "Installed": + n.Installed = v + case "Healthy": + n.Healthy = v + } + } + + n.Package, _, _ = un.NestedString(o.Object, "spec", "package") + if img, _, _ := un.NestedString(o.Object, "spec", "image"); img != "" { + n.Package = img + } + + n.State, _, _ = un.NestedString(o.Object, "spec", "desiredState") for _, c := range p.Children { n.Children = append(n.Children, toNode(c)) From f0dfff9b42a98ecb7095dafefca42274eff4a2f6 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 14:26:39 -0500 Subject: [PATCH 3/3] Fail trace integration trees on error nodes and wrong formats Decode each printed resource's error key and fail on any, since a failed lookup keeps the kind, name and namespace a case matches on. Read json output with encoding/json and yaml with sigs.k8s.io/yaml, chosen from -o, so json output that is really YAML fails. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- .../trace/trace_integration_test.go | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/cmd/crossplane/trace/trace_integration_test.go b/cmd/crossplane/trace/trace_integration_test.go index 9236918a..5a7716ac 100644 --- a/cmd/crossplane/trace/trace_integration_test.go +++ b/cmd/crossplane/trace/trace_integration_test.go @@ -20,9 +20,11 @@ package trace import ( "bytes" + "encoding/json" "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -260,7 +262,7 @@ NopClaim/test-claim (test-namespace) - - } if tc.want.tree != nil { - if diff := cmp.Diff(tc.want.tree, parseTree(t, tc.args, stdout)); diff != "" { + if diff := cmp.Diff(tc.want.tree, parseTree(t, outputFormat(tc.args), stdout)); diff != "" { t.Errorf("\n%s\ntrace tree: -want, +got:\n%s", tc.reason, diff) } @@ -316,21 +318,30 @@ type node struct { Children []*node } -// parseTree reads the tree trace printed as json or yaml; sigs.k8s.io/yaml reads both. Children are left in the -// order trace printed them, which is deterministic and the text tree's: the XR client sorts each resource's -// children by apiVersion, kind and name, and the package client orders them runtime config, revisions oldest -// first, then dependencies in lock order. -func parseTree(t *testing.T, args []string, out string) *node { +// parseTree reads the tree trace printed in format, json with encoding/json so that YAML output fails it, and +// yaml with sigs.k8s.io/yaml. Children are left in the order trace printed them, which is deterministic and the +// text tree's: the XR client sorts each resource's children by apiVersion, kind and name, and the package client +// orders them runtime config, revisions oldest first, then dependencies in lock order. +func parseTree(t *testing.T, format, out string) *node { t.Helper() type printed struct { - Object map[string]any `json:"object"` - Children []*printed `json:"children"` + Object map[string]any `json:"object"` + // Error is set when trace could not get the resource, which then keeps only the kind, name and namespace + // it was looked up by, so it would otherwise match the node a test wants. A Go error marshals as {}, + // so only the key's presence says there was one. + Error json.RawMessage `json:"error"` + Children []*printed `json:"children"` + } + + unmarshal := map[string]func([]byte, any) error{"json": json.Unmarshal, "yaml": func(b []byte, v any) error { return yaml.Unmarshal(b, v) }}[format] + if unmarshal == nil { + t.Fatalf("parseTree: unknown format %q", format) } var p printed - if err := yaml.Unmarshal([]byte(out), &p); err != nil { - t.Fatalf("cannot unmarshal the output of trace %v: %v", args, err) + if err := unmarshal([]byte(out), &p); err != nil { + t.Fatalf("cannot unmarshal the %s output of trace: %v", format, err) } var toNode func(p *printed) *node @@ -338,6 +349,10 @@ func parseTree(t *testing.T, args []string, out string) *node { o := &un.Unstructured{Object: p.Object} n := &node{Kind: o.GetKind(), Name: o.GetName(), Namespace: o.GetNamespace()} + if len(p.Error) > 0 { + t.Errorf("trace could not get %s %s: error %s", n.Kind, n.Name, p.Error) + } + conds, _, _ := un.NestedSlice(o.Object, "status", "conditions") for _, c := range conds { c, _ := c.(map[string]any) @@ -375,6 +390,15 @@ func parseTree(t *testing.T, args []string, out string) *node { return toNode(&p) } +// outputFormat returns the value of the -o flag in args. +func outputFormat(args []string) string { + if i := slices.Index(args, "-o"); i >= 0 && i+1 < len(args) { + return args[i+1] + } + + return "default" +} + // createFromFile creates the resources in path. func createFromFile(t *testing.T, cluster *integration.Cluster, path string) { t.Helper()