diff --git a/cmd/crossplane/alpha/alpha.go b/cmd/crossplane/alpha/alpha.go deleted file mode 100644 index c319693..0000000 --- a/cmd/crossplane/alpha/alpha.go +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2025 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 alpha contains alpha Crossplane CLI subcommands. -// These commands are experimental, and may be changed or removed in a future -// release. -package alpha - -import ( - "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render" -) - -// Cmd contains alpha commands. -type Cmd struct { - // Subcommands and flags will appear in the CLI help output in the same - // order they're specified here. Keep them in alphabetical order. - Render render.Cmd `cmd:"" help:"Render resources."` -} - -// Help output for crossplane alpha. -func (c *Cmd) Help() string { - return "WARNING: These commands are experimental and may be changed or removed in a future release." -} diff --git a/cmd/crossplane/alpha/render/cmd.go b/cmd/crossplane/alpha/render/cmd.go deleted file mode 100644 index 65b3cb8..0000000 --- a/cmd/crossplane/alpha/render/cmd.go +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2025 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 render implements alpha rendering commands. -package render - -import ( - "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render/op" - "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render/xr" -) - -// Cmd contains alpha render subcommands. -type Cmd struct { - // Subcommands and flags will appear in the CLI help output in the same - // order they're specified here. Keep them in alphabetical order. - Op op.Cmd `cmd:"" help:"Render an operation."` - XR xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` -} - -// Help output for crossplane alpha render. -func (c *Cmd) Help() string { - return ` -Render Crossplane resources locally using functions. - -These commands show you what resources Crossplane would create or mutate by -running function pipelines locally, without talking to a Crossplane control plane. -` -} diff --git a/cmd/crossplane/alpha/render/xr/cmd.go b/cmd/crossplane/alpha/render/xr/cmd.go deleted file mode 100644 index ef8e951..0000000 --- a/cmd/crossplane/alpha/render/xr/cmd.go +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright 2025 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 xr implements XR rendering by delegating to the existing render command. -package xr - -import ( - "github.com/alecthomas/kong" - - "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - - "github.com/crossplane/cli/v2/cmd/crossplane/render" -) - -// Cmd renders a composite resource (XR) by delegating to the existing render command. -type Cmd struct { - render.Cmd -} - -// Help prints out the help for the alpha render xr command. -func (c *Cmd) Help() string { - return ` -This command renders a composite resource (XR) by delegating to the main -crossplane render command. It supports all the same functionality. - -For composite resources (XRs), it requires a Composition in Pipeline mode and -renders the XR using composition functions. - -Functions are pulled and run using Docker by default. You can add -the following annotations to each Function to change how they're run: - - render.crossplane.io/runtime: "Development" - - Connect to a Function that is already running, instead of using Docker. This - is useful to develop and debug new Functions. The Function must be listening - at localhost:9443 and running with the --insecure flag. - - render.crossplane.io/runtime-development-target: "dns:///example.org:7443" - - Connect to a Function running somewhere other than localhost:9443. The - target uses gRPC target syntax. - - render.crossplane.io/runtime-docker-cleanup: "Orphan" - - Don't stop the Function's Docker container after rendering. - - render.crossplane.io/runtime-docker-name: "" - - create a container with that name and also reuse it as long as it is running or can be restarted. - - render.crossplane.io/runtime-docker-pull-policy: "Always" - - Always pull the Function's package, even if it already exists locally. - Other supported values are Never, or IfNotPresent. - -Use the standard DOCKER_HOST, DOCKER_API_VERSION, DOCKER_CERT_PATH, and -DOCKER_TLS_VERIFY environment variables to configure how this command connects -to the Docker daemon. - -Examples: - - # Render a composite resource. - crossplane alpha render xr xr.yaml composition.yaml functions.yaml - - # Simulate updating an XR that already exists. - crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ - --observed-resources=existing-observed-resources.yaml - - # Pass context values to the Function pipeline. - crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ - --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' - - # Pass required resources Functions can request. - crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ - --required-resources=required-resources.yaml - - # Pass credentials to Functions that need them. - crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ - --function-credentials=credentials.yaml -` -} - -// Run delegates to the existing render command. -func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { - return c.Cmd.Run(k, log) -} diff --git a/cmd/crossplane/beta/beta.go b/cmd/crossplane/beta/beta.go deleted file mode 100644 index ecbea9a..0000000 --- a/cmd/crossplane/beta/beta.go +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2023 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 beta contains beta Crossplane CLI subcommands. -// These commands are experimental, and may be changed or removed in a future -// release. -package beta - -import ( - "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert" - "github.com/crossplane/cli/v2/cmd/crossplane/beta/top" - "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace" - "github.com/crossplane/cli/v2/cmd/crossplane/beta/validate" -) - -// Cmd contains beta commands. -type Cmd struct { - // Subcommands and flags will appear in the CLI help output in the same - // order they're specified here. Keep them in alphabetical order. - Convert convert.Cmd `cmd:"" help:"Convert a Crossplane resource to a newer version or kind."` - Top top.Cmd `cmd:"" help:"Display resource (CPU/memory) usage by Crossplane related pods."` - Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource to get a detailed output of its relationships, helpful for troubleshooting."` - Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` -} - -// Help output for crossplane beta. -func (c *Cmd) Help() string { - return "WARNING: These commands may be changed or removed in a future release." -} diff --git a/cmd/crossplane/cluster/cluster.go b/cmd/crossplane/cluster/cluster.go new file mode 100644 index 0000000..ae9b5ef --- /dev/null +++ b/cmd/crossplane/cluster/cluster.go @@ -0,0 +1,27 @@ +/* +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 cluster contains commands for inspecting a Crossplane cluster. +package cluster + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/top" +) + +// Cmd contains commands for inspecting a Crossplane cluster. +type Cmd struct { + Top top.Cmd `cmd:"" help:"Display resource (CPU/memory) usage by Crossplane related pods."` +} diff --git a/cmd/crossplane/composition/composition.go b/cmd/crossplane/composition/composition.go new file mode 100644 index 0000000..f9b42c0 --- /dev/null +++ b/cmd/crossplane/composition/composition.go @@ -0,0 +1,29 @@ +/* +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 composition contains commands for working with Crossplane Compositions. +package composition + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/convert" + "github.com/crossplane/cli/v2/cmd/crossplane/render/xr" +) + +// Cmd contains commands for working with Crossplane Compositions. +type Cmd struct { + Convert convert.Cmd `cmd:"" help:"Convert a Composition to a newer version." maturity:"beta"` + Render xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` +} diff --git a/cmd/crossplane/config/config.go b/cmd/crossplane/config/config.go new file mode 100644 index 0000000..dc8f1be --- /dev/null +++ b/cmd/crossplane/config/config.go @@ -0,0 +1,49 @@ +/* +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 config contains the `crossplane config` subcommands. +package config + +// ConfigPath is the resolved config file path. It is bound by main so that +// subcommands can receive it as a Run() argument. Using a typed alias keeps +// the binding distinct from any other string value Kong may know about. +type ConfigPath string //nolint:revive // The "Config" stutter is intentional; this is the type Kong binds. + +// Cmd groups subcommands for inspecting and modifying the CLI config file. +type Cmd struct { + // Keep subcommands sorted alphabetically. + Set setCmd `cmd:"" help:"Set a config value and write it to the config file."` + View viewCmd `cmd:"" help:"Print the current effective config as YAML."` +} + +// Help returns the extended help for the config command. +func (c *Cmd) Help() string { + return ` +Manage the crossplane CLI configuration file. + +The config file location is, in priority order: + 1. The --config flag. + 2. The CROSSPLANE_CONFIG environment variable. + 3. $XDG_CONFIG_HOME/crossplane/config.yaml (or ~/.config/crossplane/config.yaml). + +Examples: + # Show the current effective config. + crossplane config view + + # Enable alpha commands. + crossplane config set features.enableAlpha true +` +} diff --git a/cmd/crossplane/config/set.go b/cmd/crossplane/config/set.go new file mode 100644 index 0000000..c1b8cfd --- /dev/null +++ b/cmd/crossplane/config/set.go @@ -0,0 +1,87 @@ +/* +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 config + +import ( + "sort" + "strconv" + "strings" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/config" +) + +type setCmd struct { + Key string `arg:"" help:"Config key to set (e.g. features.enableAlpha)."` + Value string `arg:"" help:"Value to assign."` + + fs afero.Fs +} + +type boolSetter func(c *config.Config, v bool) + +// boolKeys maps supported dotted config keys to setter functions. Adding a new +// boolean key is a single entry here. +// +//nolint:gochecknoglobals // This is a constant. +var boolKeys = map[string]boolSetter{ + "features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v }, + "features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v }, +} + +func (c *setCmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// Run sets a config value and writes the file. +func (c *setCmd) Run(path ConfigPath) error { + p := string(path) + if p == "" { + return errors.New("cannot determine config file path; pass --config or set CROSSPLANE_CONFIG") + } + + setter, ok := boolKeys[c.Key] + if !ok { + return errors.Errorf("unknown config key %q (supported: %s)", c.Key, knownKeysList()) + } + v, err := strconv.ParseBool(c.Value) + if err != nil { + return errors.Wrapf(err, "invalid bool value %q for key %s", c.Value, c.Key) + } + + cfg, err := config.Load(c.fs, p) + if err != nil { + return errors.Wrap(err, "cannot load config") + } + + setter(cfg, v) + + return config.Save(c.fs, p, cfg) +} + +func knownKeysList() string { + keys := make([]string, 0, len(boolKeys)) + for k := range boolKeys { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} diff --git a/cmd/crossplane/config/set_test.go b/cmd/crossplane/config/set_test.go new file mode 100644 index 0000000..6812ace --- /dev/null +++ b/cmd/crossplane/config/set_test.go @@ -0,0 +1,147 @@ +/* +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 config + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + + cfgpkg "github.com/crossplane/cli/v2/internal/config" +) + +func TestSetRun(t *testing.T) { + type args struct { + // preExisting, if non-empty, is written to the path before Run. + preExisting string + path ConfigPath + key string + value string + } + + type want struct { + // loaded is the Config that should be returned by Load after Run. + // nil means we don't check (e.g. error cases). + loaded *cfgpkg.Config + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "CreatesFile": { + reason: "Setting a key when the file is missing should create it with version 1.", + args: args{ + path: "/c.yaml", + key: "features.disableBeta", + value: "true", + }, + want: want{ + loaded: &cfgpkg.Config{Version: 1, Features: cfgpkg.Features{DisableBeta: true}}, + }, + }, + "UpdatesExisting": { + reason: "Setting a key should preserve other keys in the file.", + args: args{ + preExisting: "version: 1\nfeatures:\n enableAlpha: true\n", + path: "/c.yaml", + key: "features.disableBeta", + value: "true", + }, + want: want{ + loaded: &cfgpkg.Config{Version: 1, Features: cfgpkg.Features{EnableAlpha: true, DisableBeta: true}}, + }, + }, + "UnsetExisting": { + reason: "Setting a key to false should clear it without affecting other keys.", + args: args{ + preExisting: "version: 1\nfeatures:\n enableAlpha: true\n disableBeta: true\n", + path: "/c.yaml", + key: "features.disableBeta", + value: "false", + }, + want: want{ + loaded: &cfgpkg.Config{Version: 1, Features: cfgpkg.Features{EnableAlpha: true}}, + }, + }, + "UnknownKey": { + reason: "An unknown key should return an error.", + args: args{ + path: "/c.yaml", + key: "features.bogus", + value: "true", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "InvalidBool": { + reason: "An invalid bool value should return an error.", + args: args{ + path: "/c.yaml", + key: "features.enableAlpha", + value: "yes", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "EmptyPath": { + reason: "An empty resolved path should return an error.", + args: args{ + path: "", + key: "features.enableAlpha", + value: "true", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tc.args.preExisting != "" { + if err := afero.WriteFile(fs, string(tc.args.path), []byte(tc.args.preExisting), 0o600); err != nil { + t.Fatalf("setting up pre-existing file: %v", err) + } + } + + c := &setCmd{Key: tc.args.key, Value: tc.args.value, fs: fs} + err := c.Run(tc.args.path) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nRun(): -want err, +got err:\n%s", tc.reason, diff) + } + if tc.want.err != nil { + return + } + + got, err := cfgpkg.Load(fs, string(tc.args.path)) + if err != nil { + t.Fatalf("Load after Run returned error: %v", err) + } + if diff := cmp.Diff(tc.want.loaded, got); diff != "" { + t.Errorf("\n%s\nLoad after Run: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/config/view.go b/cmd/crossplane/config/view.go new file mode 100644 index 0000000..5cad808 --- /dev/null +++ b/cmd/crossplane/config/view.go @@ -0,0 +1,54 @@ +/* +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 config + +import ( + "fmt" + + "github.com/alecthomas/kong" + "github.com/spf13/afero" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + cfgpkg "github.com/crossplane/cli/v2/internal/config" +) + +type viewCmd struct { + fs afero.Fs +} + +func (c *viewCmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// Run prints the current effective config as YAML. +func (c *viewCmd) Run(k *kong.Context, path ConfigPath) error { + cfg, err := cfgpkg.Load(c.fs, string(path)) + if err != nil { + return errors.Wrap(err, "cannot load config") + } + out, err := yaml.Marshal(cfg) + if err != nil { + return errors.Wrap(err, "cannot marshal config") + } + if _, err := fmt.Fprint(k.Stdout, string(out)); err != nil { + return errors.Wrap(err, "cannot write config") + } + return nil +} diff --git a/cmd/crossplane/config/view_test.go b/cmd/crossplane/config/view_test.go new file mode 100644 index 0000000..2feb728 --- /dev/null +++ b/cmd/crossplane/config/view_test.go @@ -0,0 +1,81 @@ +/* +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 config + +import ( + "bytes" + "io" + "testing" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" +) + +func TestViewRun(t *testing.T) { + type args struct { + preExisting string + path ConfigPath + } + + cases := map[string]struct { + reason string + args args + want string + }{ + "MissingFile": { + reason: "View on a missing file should print the zero default.", + args: args{path: "/c.yaml"}, + want: "version: 1\n", + }, + "EmptyPath": { + reason: "View with an empty path should still print the zero default (Load treats it as missing).", + args: args{path: ""}, + want: "version: 1\n", + }, + "Existing": { + reason: "View on an existing file should print its parsed contents.", + args: args{ + preExisting: "version: 1\nfeatures:\n enableAlpha: true\n", + path: "/c.yaml", + }, + want: "features:\n enableAlpha: true\nversion: 1\n", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tc.args.preExisting != "" { + if err := afero.WriteFile(fs, string(tc.args.path), []byte(tc.args.preExisting), 0o600); err != nil { + t.Fatalf("setting up pre-existing file: %v", err) + } + } + + buf := &bytes.Buffer{} + kctx := &kong.Context{Kong: &kong.Kong{Stdout: buf, Stderr: io.Discard}} + + c := &viewCmd{fs: fs} + if err := c.Run(kctx, tc.args.path); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if diff := cmp.Diff(tc.want, buf.String()); diff != "" { + t.Errorf("\n%s\nRun output: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/convert/compositionenvironment/cmd.go b/cmd/crossplane/convert/compositionenvironment/cmd.go similarity index 90% rename from cmd/crossplane/beta/convert/compositionenvironment/cmd.go rename to cmd/crossplane/convert/compositionenvironment/cmd.go index 46343f5..a5850ef 100644 --- a/cmd/crossplane/beta/convert/compositionenvironment/cmd.go +++ b/cmd/crossplane/convert/compositionenvironment/cmd.go @@ -30,7 +30,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - commonIO "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert/io" + commonIO "github.com/crossplane/cli/v2/cmd/crossplane/convert/io" ) // Cmd arguments and flags for converting a Composition to use function-environment-configs. @@ -59,13 +59,13 @@ Examples: # Convert an existing Composition (Pipeline mode) leveraging native # Composition Environment to use function-environment-configs. - crossplane beta convert composition-environment composition.yaml -o composition-environment.yaml + crossplane composition convert composition-environment composition.yaml -o composition-environment.yaml # Use a different functionRef and output to stdout. - crossplane beta convert composition-environment composition.yaml --function-environment-configs-ref local-function-environment-configs + crossplane composition convert composition-environment composition.yaml --function-environment-configs-ref local-function-environment-configs # Stdin to stdout. - cat composition.yaml | ./crossplane beta convert composition-environment + cat composition.yaml | ./crossplane composition convert composition-environment ` } diff --git a/cmd/crossplane/beta/convert/compositionenvironment/converter.go b/cmd/crossplane/convert/compositionenvironment/converter.go similarity index 100% rename from cmd/crossplane/beta/convert/compositionenvironment/converter.go rename to cmd/crossplane/convert/compositionenvironment/converter.go diff --git a/cmd/crossplane/beta/convert/compositionenvironment/converter_test.go b/cmd/crossplane/convert/compositionenvironment/converter_test.go similarity index 100% rename from cmd/crossplane/beta/convert/compositionenvironment/converter_test.go rename to cmd/crossplane/convert/compositionenvironment/converter_test.go diff --git a/cmd/crossplane/beta/convert/convert.go b/cmd/crossplane/convert/convert.go similarity index 88% rename from cmd/crossplane/beta/convert/convert.go rename to cmd/crossplane/convert/convert.go index b57a769..35e1770 100644 --- a/cmd/crossplane/beta/convert/convert.go +++ b/cmd/crossplane/convert/convert.go @@ -19,7 +19,7 @@ limitations under the License. package convert import ( - "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert/compositionenvironment" + "github.com/crossplane/cli/v2/cmd/crossplane/convert/compositionenvironment" ) // Cmd converts a Crossplane resource to a newer version or a different kind. @@ -38,6 +38,6 @@ Currently supported conversions: Examples: # Convert an existing Composition to use function-environment-configs instead of native Composition Environment, # requires the composition to be in Pipeline mode already. - crossplane beta convert composition-environment composition.yaml -o composition-environment.yaml + crossplane composition convert composition-environment composition.yaml -o composition-environment.yaml ` } diff --git a/cmd/crossplane/beta/convert/io/io.go b/cmd/crossplane/convert/io/io.go similarity index 100% rename from cmd/crossplane/beta/convert/io/io.go rename to cmd/crossplane/convert/io/io.go diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index 4333e90..d3ee38c 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -18,20 +18,29 @@ limitations under the License. package main import ( + "errors" + "fmt" "os" + "strings" "github.com/alecthomas/kong" + "github.com/spf13/afero" "github.com/willabides/kongplete" "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/cli/v2/cmd/crossplane/alpha" - "github.com/crossplane/cli/v2/cmd/crossplane/beta" + "github.com/crossplane/cli/v2/cmd/crossplane/cluster" "github.com/crossplane/cli/v2/cmd/crossplane/completion" - "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/cmd/crossplane/composition" + configcmd "github.com/crossplane/cli/v2/cmd/crossplane/config" + "github.com/crossplane/cli/v2/cmd/crossplane/operation" + renderxr "github.com/crossplane/cli/v2/cmd/crossplane/render/xr" + "github.com/crossplane/cli/v2/cmd/crossplane/resource" "github.com/crossplane/cli/v2/cmd/crossplane/version" "github.com/crossplane/cli/v2/cmd/crossplane/xpkg" + "github.com/crossplane/cli/v2/internal/config" + "github.com/crossplane/cli/v2/internal/maturity" ) var _ = kong.Must(&cli{}) @@ -53,17 +62,20 @@ type cli struct { // order they're specified here. Keep them in alphabetical order. // Subcommands. - XPKG xpkg.Cmd `cmd:"" help:"Manage Crossplane packages."` - Render render.Cmd `cmd:"" help:"Render a composite resource (XR)."` + Cluster cluster.Cmd `cmd:"" help:"Inspect a Crossplane cluster." maturity:"beta"` + Composition composition.Cmd `cmd:"" help:"Work with Crossplane Compositions."` + Config configcmd.Cmd `cmd:"" help:"View and modify the crossplane CLI config file."` + Operation operation.Cmd `cmd:"" help:"Work with Crossplane Operations." maturity:"alpha"` + Resource resource.Cmd `cmd:"" help:"Work with Crossplane resources." maturity:"beta"` + Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."` + XPKG xpkg.Cmd `cmd:"" help:"Work with Crossplane packages."` - // The alpha and beta subcommands are intentionally in a separate block. We - // want them to appear after all other subcommands. - Alpha alpha.Cmd `cmd:"" help:"Alpha commands."` - Beta beta.Cmd `cmd:"" help:"Beta commands."` - Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."` + // Hidden top-level alias for render, since it's GA but has moved. + Render renderxr.Cmd `cmd:"" help:"Render Crossplane compositions locally using functions." hidden:""` // Flags. - Verbose verboseFlag `help:"Print verbose logging statements." name:"verbose"` + ConfigPath string `env:"CROSSPLANE_CONFIG" help:"Path to the crossplane CLI config file." name:"config" placeholder:"PATH"` + Verbose verboseFlag `help:"Print verbose logging statements." name:"verbose"` // Completion Completions kongplete.InstallCompletions `cmd:"" help:"Get shell (bash/zsh/fish) completions. You can source this command to get completions for the login shell. Example: 'source <(crossplane completions)'"` @@ -71,16 +83,35 @@ type cli struct { func main() { logger := logging.NewNopLogger() + + // Apply maturity gating before Parse so --help reflects the user's config. + // We need the config path before Parse runs, so look for --config in argv + // ourselves rather than parsing twice. + flagVal, err := configFlag(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "crossplane: %v\n", err) + os.Exit(1) + } + cfgPath := config.ResolvePath(flagVal) + + cfg, err := config.Load(afero.NewOsFs(), cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "crossplane: %v\n", err) + os.Exit(1) + } + parser := kong.Must(&cli{}, kong.Name("crossplane"), kong.Description("A command line tool for interacting with Crossplane."), // Binding a variable to kong context makes it available to all commands // at runtime. kong.BindTo(logger, (*logging.Logger)(nil)), + kong.BindTo(configcmd.ConfigPath(cfgPath), (*configcmd.ConfigPath)(nil)), kong.ConfigureHelp(kong.HelpOptions{ - FlagsLast: true, - Compact: true, - WrapUpperBound: 80, + FlagsLast: true, + Compact: true, + WrapUpperBound: 80, + NoExpandSubcommands: true, }), kong.UsageOnError()) @@ -88,9 +119,38 @@ func main() { kongplete.WithPredictors(completion.Predictors()), ) + maturity.Apply(parser.Model, map[maturity.Level]bool{ + // Beta features are enabled by default. + maturity.LevelBeta: !cfg.Features.DisableBeta, + // Alpha features must be explicitly enabled. + maturity.LevelAlpha: cfg.Features.EnableAlpha, + }) + ctx, err := parser.Parse(os.Args[1:]) parser.FatalIfErrorf(err) err = ctx.Run() ctx.FatalIfErrorf(err) } + +// configFlag scans argv for the --config flag and returns its value or "" if +// the config flag is not present. +func configFlag(args []string) (string, error) { + for i, a := range args { + if !strings.HasPrefix(a, "--config") { + continue + } + + if v := strings.TrimPrefix(a, "--config="); v != "" { + return v, nil + } + + if i+1 < len(args) { + return args[i+1], nil + } + + return "", errors.New("flag --config requires a value") + } + + return "", nil +} diff --git a/cmd/crossplane/operation/operation.go b/cmd/crossplane/operation/operation.go new file mode 100644 index 0000000..119935b --- /dev/null +++ b/cmd/crossplane/operation/operation.go @@ -0,0 +1,27 @@ +/* +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 operation contains commands for working with Crossplane Operations. +package operation + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/render/op" +) + +// Cmd contains commands for working with Crossplane Operations. +type Cmd struct { + Render op.Cmd `cmd:"" help:"Render an Operation."` +} diff --git a/cmd/crossplane/alpha/render/op/cmd.go b/cmd/crossplane/render/op/cmd.go similarity index 94% rename from cmd/crossplane/alpha/render/op/cmd.go rename to cmd/crossplane/render/op/cmd.go index 8837285..6abc564 100644 --- a/cmd/crossplane/alpha/render/op/cmd.go +++ b/cmd/crossplane/render/op/cmd.go @@ -110,49 +110,49 @@ to the Docker daemon. Examples: # Render an Operation. - crossplane alpha render op operation.yaml functions.yaml + crossplane operation render operation.yaml functions.yaml # Pin the Crossplane version used for rendering. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --crossplane-version=v2.2.1 # Use a local crossplane binary instead of Docker. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --crossplane-binary=/usr/local/bin/crossplane # Pass context values to the function pipeline. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' # Pass required resources functions can request. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --required-resources=required-resources.yaml # Pass OpenAPI schemas for functions that need them. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --required-schemas=schemas/ # Render a WatchOperation with a watched resource. - crossplane alpha render op watchoperation.yaml functions.yaml \ + crossplane operation render watchoperation.yaml functions.yaml \ --watched-resource=watched-configmap.yaml # Pass credentials to functions that need them. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ --function-credentials=credentials.yaml # Include function results and context in output. - crossplane alpha render op operation.yaml functions.yaml -r -c + crossplane operation render operation.yaml functions.yaml -r -c # Include the full Operation with original spec and metadata. - crossplane alpha render op operation.yaml functions.yaml -o + crossplane operation render operation.yaml functions.yaml -o # Override function annotations for remote Docker daemon. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ -a render.crossplane.io/runtime-docker-publish-address=0.0.0.0 \ -a render.crossplane.io/runtime-docker-target=192.168.1.100 # Use development runtime with custom target. - crossplane alpha render op operation.yaml functions.yaml \ + crossplane operation render operation.yaml functions.yaml \ -a render.crossplane.io/runtime=Development \ -a render.crossplane.io/runtime-development-target=localhost:9444 ` diff --git a/cmd/crossplane/alpha/render/op/cmd_test.go b/cmd/crossplane/render/op/cmd_test.go similarity index 100% rename from cmd/crossplane/alpha/render/op/cmd_test.go rename to cmd/crossplane/render/op/cmd_test.go diff --git a/cmd/crossplane/alpha/render/op/load.go b/cmd/crossplane/render/op/load.go similarity index 100% rename from cmd/crossplane/alpha/render/op/load.go rename to cmd/crossplane/render/op/load.go diff --git a/cmd/crossplane/alpha/render/op/load_test.go b/cmd/crossplane/render/op/load_test.go similarity index 100% rename from cmd/crossplane/alpha/render/op/load_test.go rename to cmd/crossplane/render/op/load_test.go diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml b/cmd/crossplane/render/op/testdata/cmd/functions.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml rename to cmd/crossplane/render/op/testdata/cmd/functions.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml b/cmd/crossplane/render/op/testdata/cmd/operation-not-op.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml rename to cmd/crossplane/render/op/testdata/cmd/operation-not-op.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml b/cmd/crossplane/render/op/testdata/cmd/operation.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml rename to cmd/crossplane/render/op/testdata/cmd/operation.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml b/cmd/crossplane/render/op/testdata/cmd/output/include-full-operation.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml rename to cmd/crossplane/render/op/testdata/cmd/output/include-full-operation.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml b/cmd/crossplane/render/op/testdata/cmd/output/include-function-results.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml rename to cmd/crossplane/render/op/testdata/cmd/output/include-function-results.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml b/cmd/crossplane/render/op/testdata/cmd/output/success.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml rename to cmd/crossplane/render/op/testdata/cmd/output/success.yaml diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml b/cmd/crossplane/render/op/testdata/cmd/watched-multi.yaml similarity index 100% rename from cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml rename to cmd/crossplane/render/op/testdata/cmd/watched-multi.yaml diff --git a/cmd/crossplane/render/render.go b/cmd/crossplane/render/render.go index 63da1a3..5cb3f5e 100644 --- a/cmd/crossplane/render/render.go +++ b/cmd/crossplane/render/render.go @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +// Package render implements helpers shared by the render subcommands +// (xr and op). package render import ( @@ -215,3 +217,22 @@ func StopFunctionRuntimes(log logging.Logger, fa *FunctionAddresses) { log.Info("Error stopping function runtimes", "error", err) } } + +// OverrideFunctionAnnotations applies annotation overrides from flags to +// functions. +func OverrideFunctionAnnotations(fns []pkgv1.Function, annotations []string) error { + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = make(map[string]string) + } + for _, annotation := range annotations { + parts := strings.SplitN(annotation, "=", 2) + if len(parts) != 2 { + return errors.Errorf("invalid function annotation format %q, expected key=value", annotation) + } + key, value := parts[0], parts[1] + fns[i].Annotations[key] = value // Flags override existing annotations + } + } + return nil +} diff --git a/cmd/crossplane/render/cmd.go b/cmd/crossplane/render/xr/cmd.go similarity index 85% rename from cmd/crossplane/render/cmd.go rename to cmd/crossplane/render/xr/cmd.go index 9895cb0..ed3cfa5 100644 --- a/cmd/crossplane/render/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -14,13 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package render implements composition rendering using composition functions. -package render +// Package xr implements composite resource (XR) rendering. +package xr import ( "context" "fmt" - "strings" "time" "dario.cat/mergo" @@ -37,14 +36,14 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" - pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" ) -// Cmd arguments and flags for render subcommand. +// Cmd arguments and flags for the `render xr` subcommand. type Cmd struct { - EngineFlags `prefix:""` + render.EngineFlags `prefix:""` // Arguments. CompositeResource string `arg:"" help:"A YAML file specifying the composite resource (XR) to render." predictor:"yaml_file" type:"existingfile"` @@ -70,7 +69,7 @@ type Cmd struct { fs afero.Fs // newEngine constructs the render Engine. - newEngine func(*EngineFlags, logging.Logger) Engine + newEngine func(*render.EngineFlags, logging.Logger) render.Engine } // Help prints out the help for the render command. @@ -127,43 +126,43 @@ to the Docker daemon. Examples: # Simulate creating a new XR. - crossplane render xr.yaml composition.yaml functions.yaml + crossplane composition render xr.yaml composition.yaml functions.yaml # Simulate updating an XR that already exists. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --observed-resources=existing-observed-resources.yaml # Pin the Crossplane version used for rendering. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --crossplane-version=v2.3.0 # Use a local crossplane binary instead of Docker. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --crossplane-binary=/usr/local/bin/crossplane # Pass context values to the Function pipeline. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' # Pass required resources Functions in the pipeline can request. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --required-resources=required-resources.yaml # Pass OpenAPI schemas for Functions that need them. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --required-schemas=schemas/ # Pass credentials to Functions in the pipeline that need them. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ --function-credentials=credentials.yaml # Override function annotations for a remote Docker daemon. - DOCKER_HOST=tcp://192.168.1.100:2376 crossplane render xr.yaml composition.yaml functions.yaml \ + DOCKER_HOST=tcp://192.168.1.100:2376 crossplane composition render xr.yaml composition.yaml functions.yaml \ -a render.crossplane.io/runtime-docker-publish-address=0.0.0.0 \ -a render.crossplane.io/runtime-docker-target=192.168.1.100 # Force all functions to use development runtime. - crossplane render xr.yaml composition.yaml functions.yaml \ + crossplane composition render xr.yaml composition.yaml functions.yaml \ -a render.crossplane.io/runtime=Development \ -a render.crossplane.io/runtime-development-target=localhost:9444 ` @@ -172,19 +171,19 @@ Examples: // AfterApply implements kong.AfterApply. func (c *Cmd) AfterApply() error { c.fs = afero.NewOsFs() - c.newEngine = NewEngineFromFlags + c.newEngine = render.NewEngineFromFlags return nil } // Run render. func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit // Orchestration is inherently complex. - xr, err := LoadCompositeResource(c.fs, c.CompositeResource) + xr, err := render.LoadCompositeResource(c.fs, c.CompositeResource) if err != nil { return errors.Wrapf(err, "cannot load composite resource from %q", c.CompositeResource) } - comp, err := LoadComposition(c.fs, c.Composition) + comp, err := render.LoadComposition(c.fs, c.Composition) if err != nil { return errors.Wrapf(err, "cannot load Composition from %q", c.Composition) } @@ -221,18 +220,18 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit return errors.Errorf("render only supports Composition Function pipelines: Composition %q must use spec.mode: Pipeline", comp.GetName()) } - fns, err := LoadFunctions(c.fs, c.Functions) + fns, err := render.LoadFunctions(c.fs, c.Functions) if err != nil { return errors.Wrapf(err, "cannot load functions from %q", c.Functions) } // Apply global annotation overrides to each function - if err := OverrideFunctionAnnotations(fns, c.FunctionAnnotations); err != nil { + if err := render.OverrideFunctionAnnotations(fns, c.FunctionAnnotations); err != nil { return errors.Wrap(err, "cannot apply function annotation overrides") } if c.XRD != "" { - xrd, err := LoadXRD(c.fs, c.XRD) + xrd, err := render.LoadXRD(c.fs, c.XRD) if err != nil { return errors.Wrapf(err, "cannot load XRD from %q", c.XRD) } @@ -242,14 +241,14 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit return errors.Wrapf(err, "cannot derive composite CRD from XRD %q", xrd.GetName()) } - if err := DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd); err != nil { + if err := render.DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd); err != nil { return errors.Wrapf(err, "cannot default values for XR %q", xr.GetName()) } } fcreds := []corev1.Secret{} if c.FunctionCredentials != "" { - fcreds, err = LoadCredentials(c.fs, c.FunctionCredentials) + fcreds, err = render.LoadCredentials(c.fs, c.FunctionCredentials) if err != nil { return errors.Wrapf(err, "cannot load secrets from %q", c.FunctionCredentials) } @@ -257,7 +256,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit ors := []composed.Unstructured{} if c.ObservedResources != "" { - ors, err = LoadObservedResources(c.fs, c.ObservedResources) + ors, err = render.LoadObservedResources(c.fs, c.ObservedResources) if err != nil { return errors.Wrapf(err, "cannot load observed composed resources from %q", c.ObservedResources) } @@ -265,7 +264,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit ers := []unstructured.Unstructured{} if c.ExtraResources != "" { - ers, err = LoadRequiredResources(c.fs, c.ExtraResources) + ers, err = render.LoadRequiredResources(c.fs, c.ExtraResources) if err != nil { return errors.Wrapf(err, "cannot load extra resources from %q", c.ExtraResources) } @@ -273,7 +272,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit rrs := []unstructured.Unstructured{} if c.RequiredResources != "" { - rrs, err = LoadRequiredResources(c.fs, c.RequiredResources) + rrs, err = render.LoadRequiredResources(c.fs, c.RequiredResources) if err != nil { return errors.Wrapf(err, "cannot load required resources from %q", c.RequiredResources) } @@ -285,7 +284,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit // Load required schemas rsc := []spec3.OpenAPI{} if c.RequiredSchemas != "" { - rsc, err = LoadRequiredSchemas(c.fs, c.RequiredSchemas) + rsc, err = render.LoadRequiredSchemas(c.fs, c.RequiredSchemas) if err != nil { return errors.Wrapf(err, "cannot load required schemas from %q", c.RequiredSchemas) } @@ -305,12 +304,12 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit return err } - raw, err := BuildContextData(c.fs, c.ContextFiles, c.ContextValues) + raw, err := render.BuildContextData(c.fs, c.ContextFiles, c.ContextValues) if err != nil { return errors.Wrap(err, "cannot build context data") } - parsed, err := ParseContextData(raw) + parsed, err := render.ParseContextData(raw) if err != nil { return errors.Wrap(err, "cannot parse context data") } @@ -337,11 +336,11 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit defer cleanup() // Start function runtimes to get their addresses. - fnAddrs, err := StartFunctionRuntimes(ctx, log, fns) + fnAddrs, err := render.StartFunctionRuntimes(ctx, log, fns) if err != nil { return errors.Wrap(err, "cannot start function runtimes") } - defer StopFunctionRuntimes(log, fnAddrs) + defer render.StopFunctionRuntimes(log, fnAddrs) addrs := fnAddrs.Addresses() if ctxHandle != nil { @@ -349,7 +348,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit } // Build and execute the render request. - in := CompositionInputs{ + in := render.CompositionInputs{ CompositeResource: xr, Composition: comp, FunctionAddrs: addrs, @@ -358,7 +357,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit RequiredSchemas: rsc, FunctionCredentials: fcreds, } - req, err := BuildCompositeRequest(in) + req, err := render.BuildCompositeRequest(in) if err != nil { return errors.Wrap(err, "cannot build render request") } @@ -373,7 +372,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit return errors.New("render response does not contain a composite output") } - out, err := ParseCompositeResponse(compositeOut) + out, err := render.ParseCompositeResponse(compositeOut) if err != nil { return errors.Wrap(err, "cannot parse render response") } @@ -409,7 +408,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit for i := range out.ComposedResources { _, _ = fmt.Fprintln(k.Stdout, "---") if err := s.Encode(&out.ComposedResources[i], k.Stdout); err != nil { - return errors.Wrapf(err, "cannot marshal composed resource %q to YAML", out.ComposedResources[i].GetAnnotations()[AnnotationKeyCompositionResourceName]) + return errors.Wrapf(err, "cannot marshal composed resource %q to YAML", out.ComposedResources[i].GetAnnotations()[render.AnnotationKeyCompositionResourceName]) } } @@ -431,22 +430,3 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit return nil } - -// OverrideFunctionAnnotations applies annotation overrides from flags to -// functions. -func OverrideFunctionAnnotations(fns []pkgv1.Function, annotations []string) error { - for i := range fns { - if fns[i].Annotations == nil { - fns[i].Annotations = make(map[string]string) - } - for _, annotation := range annotations { - parts := strings.SplitN(annotation, "=", 2) - if len(parts) != 2 { - return errors.Errorf("invalid function annotation format %q, expected key=value", annotation) - } - key, value := parts[0], parts[1] - fns[i].Annotations[key] = value // Flags override existing annotations - } - } - return nil -} diff --git a/cmd/crossplane/render/cmd_test.go b/cmd/crossplane/render/xr/cmd_test.go similarity index 96% rename from cmd/crossplane/render/cmd_test.go rename to cmd/crossplane/render/xr/cmd_test.go index 6f4b277..0d740ba 100644 --- a/cmd/crossplane/render/cmd_test.go +++ b/cmd/crossplane/render/xr/cmd_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package render +package xr import ( "bytes" @@ -35,6 +35,7 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/cmd/crossplane/render" renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" _ "embed" @@ -76,8 +77,8 @@ var includeFunctionResultsOutput string //go:embed testdata/cmd/output/include-full-xr.yaml var includeFullXROutput string -func newEngineFunc(engine Engine) func(*EngineFlags, logging.Logger) Engine { - return func(*EngineFlags, logging.Logger) Engine { +func newEngineFunc(engine render.Engine) func(*render.EngineFlags, logging.Logger) render.Engine { + return func(*render.EngineFlags, logging.Logger) render.Engine { return engine } } @@ -144,7 +145,7 @@ func TestCmdRun(t *testing.T) { Functions: "functions.yaml", Timeout: time.Minute, fs: newTestFS(nil), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { return &renderv1alpha1.RenderResponse{ Output: &renderv1alpha1.RenderResponse_Composite{ @@ -376,7 +377,7 @@ func TestCmdRun(t *testing.T) { Functions: "functions.yaml", Timeout: time.Minute, fs: newTestFS(nil), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { return func() {}, errors.New("setup blew up") }, @@ -394,7 +395,7 @@ func TestCmdRun(t *testing.T) { Functions: "functions.yaml", Timeout: time.Minute, fs: newTestFS(nil), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { return nil, errors.New("render blew up") }, @@ -412,7 +413,7 @@ func TestCmdRun(t *testing.T) { Functions: "functions.yaml", Timeout: time.Minute, fs: newTestFS(nil), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { return &renderv1alpha1.RenderResponse{}, nil }, @@ -431,7 +432,7 @@ func TestCmdRun(t *testing.T) { IncludeFunctionResults: true, Timeout: time.Minute, fs: newTestFS(nil), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { return &renderv1alpha1.RenderResponse{ Output: &renderv1alpha1.RenderResponse_Composite{ @@ -463,7 +464,7 @@ func TestCmdRun(t *testing.T) { IncludeFullXR: true, Timeout: time.Minute, fs: newTestFS(map[string]string{"xr.yaml": xrWithExtraSpecYAML}), - newEngine: newEngineFunc(&MockEngine{ + newEngine: newEngineFunc(&render.MockEngine{ MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { return &renderv1alpha1.RenderResponse{ Output: &renderv1alpha1.RenderResponse_Composite{ diff --git a/cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml b/cmd/crossplane/render/xr/testdata/cmd/composition-label-mismatch.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml rename to cmd/crossplane/render/xr/testdata/cmd/composition-label-mismatch.yaml diff --git a/cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml b/cmd/crossplane/render/xr/testdata/cmd/composition-not-pipeline.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml rename to cmd/crossplane/render/xr/testdata/cmd/composition-not-pipeline.yaml diff --git a/cmd/crossplane/render/testdata/cmd/composition.yaml b/cmd/crossplane/render/xr/testdata/cmd/composition.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/composition.yaml rename to cmd/crossplane/render/xr/testdata/cmd/composition.yaml diff --git a/cmd/crossplane/render/testdata/cmd/functions.yaml b/cmd/crossplane/render/xr/testdata/cmd/functions.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/functions.yaml rename to cmd/crossplane/render/xr/testdata/cmd/functions.yaml diff --git a/cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml b/cmd/crossplane/render/xr/testdata/cmd/output/include-full-xr.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml rename to cmd/crossplane/render/xr/testdata/cmd/output/include-full-xr.yaml diff --git a/cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml b/cmd/crossplane/render/xr/testdata/cmd/output/include-function-results.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml rename to cmd/crossplane/render/xr/testdata/cmd/output/include-function-results.yaml diff --git a/cmd/crossplane/render/testdata/cmd/output/success.yaml b/cmd/crossplane/render/xr/testdata/cmd/output/success.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/output/success.yaml rename to cmd/crossplane/render/xr/testdata/cmd/output/success.yaml diff --git a/cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml b/cmd/crossplane/render/xr/testdata/cmd/xr-extra-spec.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml rename to cmd/crossplane/render/xr/testdata/cmd/xr-extra-spec.yaml diff --git a/cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml b/cmd/crossplane/render/xr/testdata/cmd/xr-with-selector.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml rename to cmd/crossplane/render/xr/testdata/cmd/xr-with-selector.yaml diff --git a/cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml b/cmd/crossplane/render/xr/testdata/cmd/xr-wrong-apiversion.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml rename to cmd/crossplane/render/xr/testdata/cmd/xr-wrong-apiversion.yaml diff --git a/cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml b/cmd/crossplane/render/xr/testdata/cmd/xr-wrong-kind.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml rename to cmd/crossplane/render/xr/testdata/cmd/xr-wrong-kind.yaml diff --git a/cmd/crossplane/render/testdata/cmd/xr.yaml b/cmd/crossplane/render/xr/testdata/cmd/xr.yaml similarity index 100% rename from cmd/crossplane/render/testdata/cmd/xr.yaml rename to cmd/crossplane/render/xr/testdata/cmd/xr.yaml diff --git a/cmd/crossplane/resource/resource.go b/cmd/crossplane/resource/resource.go new file mode 100644 index 0000000..c930152 --- /dev/null +++ b/cmd/crossplane/resource/resource.go @@ -0,0 +1,29 @@ +/* +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 resource contains commands for working with Crossplane resources. +package resource + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/trace" + "github.com/crossplane/cli/v2/cmd/crossplane/validate" +) + +// Cmd contains commands for working with Crossplane resources. +type Cmd struct { + Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource for troubleshooting."` + Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` +} diff --git a/cmd/crossplane/beta/top/top.go b/cmd/crossplane/top/top.go similarity index 98% rename from cmd/crossplane/beta/top/top.go rename to cmd/crossplane/top/top.go index 4f46736..2093667 100644 --- a/cmd/crossplane/beta/top/top.go +++ b/cmd/crossplane/top/top.go @@ -64,13 +64,13 @@ Similar to kubectl top pods, it requires Metrics Server to be correctly configur Examples: # Show resources utilization for all Crossplane pods in the default 'crossplane-system' namespace in a tabular format. - crossplane beta top + crossplane cluster top # Show resources utilization for all Crossplane pods in a specified namespace in a tabular format. - crossplane beta top -n + crossplane cluster top -n # Add summary of resources utilization for all Crossplane pods in the default 'crossplane-system' on top of the results. - crossplane beta top -s + crossplane cluster top -s ` } diff --git a/cmd/crossplane/beta/top/top_test.go b/cmd/crossplane/top/top_test.go similarity index 100% rename from cmd/crossplane/beta/top/top_test.go rename to cmd/crossplane/top/top_test.go diff --git a/cmd/crossplane/beta/trace/internal/printer/default.go b/cmd/crossplane/trace/internal/printer/default.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/default.go rename to cmd/crossplane/trace/internal/printer/default.go diff --git a/cmd/crossplane/beta/trace/internal/printer/default_test.go b/cmd/crossplane/trace/internal/printer/default_test.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/default_test.go rename to cmd/crossplane/trace/internal/printer/default_test.go diff --git a/cmd/crossplane/beta/trace/internal/printer/dot.go b/cmd/crossplane/trace/internal/printer/dot.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/dot.go rename to cmd/crossplane/trace/internal/printer/dot.go diff --git a/cmd/crossplane/beta/trace/internal/printer/dot_test.go b/cmd/crossplane/trace/internal/printer/dot_test.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/dot_test.go rename to cmd/crossplane/trace/internal/printer/dot_test.go diff --git a/cmd/crossplane/beta/trace/internal/printer/json.go b/cmd/crossplane/trace/internal/printer/json.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/json.go rename to cmd/crossplane/trace/internal/printer/json.go diff --git a/cmd/crossplane/beta/trace/internal/printer/json_test.go b/cmd/crossplane/trace/internal/printer/json_test.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/json_test.go rename to cmd/crossplane/trace/internal/printer/json_test.go diff --git a/cmd/crossplane/beta/trace/internal/printer/printer.go b/cmd/crossplane/trace/internal/printer/printer.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/printer.go rename to cmd/crossplane/trace/internal/printer/printer.go diff --git a/cmd/crossplane/beta/trace/internal/printer/printer_test.go b/cmd/crossplane/trace/internal/printer/printer_test.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/printer_test.go rename to cmd/crossplane/trace/internal/printer/printer_test.go diff --git a/cmd/crossplane/beta/trace/internal/printer/yaml.go b/cmd/crossplane/trace/internal/printer/yaml.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/yaml.go rename to cmd/crossplane/trace/internal/printer/yaml.go diff --git a/cmd/crossplane/beta/trace/internal/printer/yaml_test.go b/cmd/crossplane/trace/internal/printer/yaml_test.go similarity index 100% rename from cmd/crossplane/beta/trace/internal/printer/yaml_test.go rename to cmd/crossplane/trace/internal/printer/yaml_test.go diff --git a/cmd/crossplane/beta/trace/trace.go b/cmd/crossplane/trace/trace.go similarity index 95% rename from cmd/crossplane/beta/trace/trace.go rename to cmd/crossplane/trace/trace.go index 1c58c5b..526b314 100644 --- a/cmd/crossplane/beta/trace/trace.go +++ b/cmd/crossplane/trace/trace.go @@ -36,11 +36,11 @@ import ( "github.com/crossplane/crossplane/apis/v2/pkg" - "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace/internal/printer" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xpkg" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm" "github.com/crossplane/cli/v2/cmd/crossplane/internal" + "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" ) const ( @@ -88,29 +88,29 @@ mykind.v1alpha1.example.org. Examples: # Trace a MyKind resource (mykinds.example.org/v1alpha1) named 'my-res' in the namespace 'my-ns' - crossplane beta trace mykind my-res -n my-ns + crossplane resource trace mykind my-res -n my-ns # Trace all MyKind resources (mykinds.example.org/v1alpha1) in the namespace 'my-ns' - crossplane beta trace mykind -n my-ns + crossplane resource trace mykind -n my-ns # Output wide format, showing full errors and condition messages, and other useful info # depending on the target type, e.g. composed resources names for composite resources or image used for packages - crossplane beta trace mykind my-res -n my-ns -o wide + crossplane resource trace mykind my-res -n my-ns -o wide # Show connection secrets in the output - crossplane beta trace mykind my-res -n my-ns --show-connection-secrets + crossplane resource trace mykind my-res -n my-ns --show-connection-secrets # Output a graph in dot format and pipe to dot to generate a png - crossplane beta trace mykind my-res -n my-ns -o dot | dot -Tpng -o output.png + crossplane resource trace mykind my-res -n my-ns -o dot | dot -Tpng -o output.png # Output all retrieved resources to json and pipe to jq to have it coloured - crossplane beta trace mykind my-res -n my-ns -o json | jq + crossplane resource trace mykind my-res -n my-ns -o json | jq # Output debug logs to stderr while redirecting a dot formatted graph to dot - crossplane beta trace mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png + crossplane resource trace mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png # Watch a resource continuously until it is deleted - crossplane beta trace mykind my-res -n my-ns --watch + crossplane resource trace mykind my-res -n my-ns --watch ` } diff --git a/cmd/crossplane/beta/trace/trace_test.go b/cmd/crossplane/trace/trace_test.go similarity index 100% rename from cmd/crossplane/beta/trace/trace_test.go rename to cmd/crossplane/trace/trace_test.go diff --git a/cmd/crossplane/beta/trace/watch.go b/cmd/crossplane/trace/watch.go similarity index 98% rename from cmd/crossplane/beta/trace/watch.go rename to cmd/crossplane/trace/watch.go index 6ec641c..6cae23c 100644 --- a/cmd/crossplane/beta/trace/watch.go +++ b/cmd/crossplane/trace/watch.go @@ -34,8 +34,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace/internal/printer" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer" ) // Bubble Tea messages for watch mode. diff --git a/cmd/crossplane/beta/validate/cache.go b/cmd/crossplane/validate/cache.go similarity index 100% rename from cmd/crossplane/beta/validate/cache.go rename to cmd/crossplane/validate/cache.go diff --git a/cmd/crossplane/beta/validate/cache_test.go b/cmd/crossplane/validate/cache_test.go similarity index 100% rename from cmd/crossplane/beta/validate/cache_test.go rename to cmd/crossplane/validate/cache_test.go diff --git a/cmd/crossplane/beta/validate/cmd.go b/cmd/crossplane/validate/cmd.go similarity index 91% rename from cmd/crossplane/beta/validate/cmd.go rename to cmd/crossplane/validate/cmd.go index b1d0ae0..a943ab0 100644 --- a/cmd/crossplane/beta/validate/cmd.go +++ b/cmd/crossplane/validate/cmd.go @@ -55,7 +55,7 @@ type Cmd struct { func (c *Cmd) Help() string { return ` This command validates the provided Crossplane resources against the schemas of the provided extensions like XRDs, -CRDs, providers, functions and configurations. The output of the "crossplane render" command can be +CRDs, providers, functions and configurations. The output of the "crossplane composition render" command can be piped to this validate command in order to rapidly validate on the outputs of the composition development experience. If providers or configurations are provided as extensions, they will be downloaded and loaded as CRDs before performing @@ -68,24 +68,24 @@ any Crossplane instance or control plane to be running or configured. Examples: # Validate all resources in the resources.yaml file against the extensions in the extensions.yaml file - crossplane beta validate extensions.yaml resources.yaml + crossplane resource validate extensions.yaml resources.yaml # Validate all resources in the resourceDir folder against the extensions in the crossplane.yaml file and extensionsDir folder - crossplane beta validate crossplane.yaml,extensionsDir/ resourceDir/ + crossplane resource validate crossplane.yaml,extensionsDir/ resourceDir/ # Validate all resources in the resources.yaml file against the extensions in the extensions.yaml file using a specific Crossplane image version - crossplane beta validate extensions.yaml resources.yaml --crossplane-image=xpkg.crossplane.io/crossplane/crossplane:v1.20.0 + crossplane resource validate extensions.yaml resources.yaml --crossplane-image=xpkg.crossplane.io/crossplane/crossplane:v1.20.0 # Validate all resources in the resourceDir folder against the extensions in the extensionsDir folder and skip # success logs - crossplane beta validate extensionsDir/ resourceDir/ --skip-success-results + crossplane resource validate extensionsDir/ resourceDir/ --skip-success-results # Validate the output of the render command against the extensions in the extensionsDir folder - crossplane render xr.yaml composition.yaml func.yaml --include-full-xr | crossplane beta validate extensionsDir/ - + crossplane composition render xr.yaml composition.yaml func.yaml --include-full-xr | crossplane resource validate extensionsDir/ - # Validate all resources in the resourceDir folder against the extensions in the extensionsDir folder using provided # cache directory and clean the cache directory before downloading schemas - crossplane beta validate extensionsDir/ resourceDir/ --cache-dir .cache --clean-cache + crossplane resource validate extensionsDir/ resourceDir/ --cache-dir .cache --clean-cache ` } diff --git a/cmd/crossplane/beta/validate/image.go b/cmd/crossplane/validate/image.go similarity index 100% rename from cmd/crossplane/beta/validate/image.go rename to cmd/crossplane/validate/image.go diff --git a/cmd/crossplane/beta/validate/image_test.go b/cmd/crossplane/validate/image_test.go similarity index 100% rename from cmd/crossplane/beta/validate/image_test.go rename to cmd/crossplane/validate/image_test.go diff --git a/cmd/crossplane/beta/validate/manager.go b/cmd/crossplane/validate/manager.go similarity index 100% rename from cmd/crossplane/beta/validate/manager.go rename to cmd/crossplane/validate/manager.go diff --git a/cmd/crossplane/beta/validate/manager_test.go b/cmd/crossplane/validate/manager_test.go similarity index 100% rename from cmd/crossplane/beta/validate/manager_test.go rename to cmd/crossplane/validate/manager_test.go diff --git a/cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml b/cmd/crossplane/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml similarity index 100% rename from cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml rename to cmd/crossplane/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml diff --git a/cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml b/cmd/crossplane/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml similarity index 100% rename from cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml rename to cmd/crossplane/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml diff --git a/cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml b/cmd/crossplane/validate/testdata/folder/nested-folder/resource-a.yaml similarity index 100% rename from cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml rename to cmd/crossplane/validate/testdata/folder/nested-folder/resource-a.yaml diff --git a/cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml b/cmd/crossplane/validate/testdata/folder/resource-b.yaml similarity index 100% rename from cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml rename to cmd/crossplane/validate/testdata/folder/resource-b.yaml diff --git a/cmd/crossplane/beta/validate/testdata/resources.yaml b/cmd/crossplane/validate/testdata/resources.yaml similarity index 100% rename from cmd/crossplane/beta/validate/testdata/resources.yaml rename to cmd/crossplane/validate/testdata/resources.yaml diff --git a/cmd/crossplane/beta/validate/unknown_fields.go b/cmd/crossplane/validate/unknown_fields.go similarity index 100% rename from cmd/crossplane/beta/validate/unknown_fields.go rename to cmd/crossplane/validate/unknown_fields.go diff --git a/cmd/crossplane/beta/validate/validate.go b/cmd/crossplane/validate/validate.go similarity index 100% rename from cmd/crossplane/beta/validate/validate.go rename to cmd/crossplane/validate/validate.go diff --git a/cmd/crossplane/beta/validate/validate_test.go b/cmd/crossplane/validate/validate_test.go similarity index 100% rename from cmd/crossplane/beta/validate/validate_test.go rename to cmd/crossplane/validate/validate_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a3a5b23 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +/* +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 config implements loading the crossplane CLI config file. +package config + +import ( + iofs "io/fs" + "os" + "path/filepath" + + "github.com/spf13/afero" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +const version1 = 1 + +// Config is the on-disk configuration for the crossplane CLI. +type Config struct { + // Version is the version of the config file. + Version int `json:"version"` + + Features Features `json:"features,omitzero"` +} + +// Features configures feature visibility. +type Features struct { + EnableAlpha bool `json:"enableAlpha,omitempty"` + DisableBeta bool `json:"disableBeta,omitempty"` +} + +// Load reads a Config from path. A missing file is not an error; the zero +// Config is returned. Unknown fields in the file are an error so typos in +// flag names surface immediately. +func Load(fs afero.Fs, path string) (*Config, error) { + if path == "" { + return &Config{Version: version1}, nil + } + data, err := afero.ReadFile(fs, path) + if err != nil { + if errors.Is(err, iofs.ErrNotExist) { + return &Config{Version: version1}, nil + } + return nil, errors.Wrapf(err, "cannot read config file %s", path) + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, errors.Wrapf(err, "cannot parse config file %s", path) + } + + if cfg.Version != version1 { + return nil, errors.Errorf("unsupported config version %d", cfg.Version) + } + + return cfg, nil +} + +// ResolvePath returns the path to the config file, in priority order: +// 1. flag - the value of the --config flag, if any. +// 2. The CROSSPLANE_CONFIG environment variable. +// 3. DefaultPath() (XDG/HOME-derived). +// +// An empty string is returned only if no source produces one. +func ResolvePath(flag string) string { + if flag != "" { + return flag + } + if v := os.Getenv("CROSSPLANE_CONFIG"); v != "" { + return v + } + return defaultPath() +} + +// defaultPath returns the default location of the config file: +// $XDG_CONFIG_HOME/crossplane/config.yaml, falling back to +// ~/.config/crossplane/config.yaml when XDG_CONFIG_HOME is unset. +// Returns "" if a home directory cannot be determined. +func defaultPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "crossplane", "config.yaml") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".config", "crossplane", "config.yaml") +} + +// Save writes cfg as YAML to path. It creates parent directories with mode +// 0o755 and writes the file with mode 0o600. An empty path is an error. +func Save(fs afero.Fs, path string, cfg *Config) error { + if path == "" { + return errors.New("cannot save config: empty path") + } + if cfg.Version == 0 { + cfg.Version = version1 + } + data, err := yaml.Marshal(cfg) + if err != nil { + return errors.Wrap(err, "cannot marshal config") + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := fs.MkdirAll(dir, 0o755); err != nil { + return errors.Wrapf(err, "cannot create config directory %s", dir) + } + } + if err := afero.WriteFile(fs, path, data, 0o600); err != nil { + return errors.Wrapf(err, "cannot write config file %s", path) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..07c02ba --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,301 @@ +/* +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 config + +import ( + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" +) + +func TestLoad(t *testing.T) { + type args struct { + fs afero.Fs + path string + } + + type want struct { + cfg *Config + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "EmptyPath": { + reason: "An empty path should yield a zero Config without error.", + args: args{ + fs: afero.NewMemMapFs(), + path: "", + }, + want: want{ + cfg: &Config{Version: version1}, + }, + }, + "MissingFile": { + reason: "A path that does not exist should yield a zero Config without error.", + args: args{ + fs: afero.NewMemMapFs(), + path: "/nope.yaml", + }, + want: want{ + cfg: &Config{Version: version1}, + }, + }, + "Valid": { + reason: "A valid config file should be parsed into a Config.", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "/config.yaml", []byte("version: 1\nfeatures:\n disableBeta: true\n enableAlpha: false\n"), 0o600) + return fs + }(), + path: "/config.yaml", + }, + want: want{ + cfg: &Config{ + Version: 1, + Features: Features{DisableBeta: true}, + }, + }, + }, + "UnsupportedVersion": { + reason: "A config file with an unsupported version should return an error.", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "/config.yaml", []byte("version: 2\n"), 0o600) + return fs + }(), + path: "/config.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "MissingVersion": { + reason: "A config file without a version should return an error.", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "/config.yaml", []byte("features:\n enableAlpha: true\n"), 0o600) + return fs + }(), + path: "/config.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "InvalidYAML": { + reason: "A malformed YAML file should return an error.", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "/config.yaml", []byte("version: : :\n"), 0o600) + return fs + }(), + path: "/config.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := Load(tc.args.fs, tc.args.path) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nLoad(...): -want err, +got err:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.cfg, got); diff != "" { + t.Errorf("\n%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSave(t *testing.T) { + type args struct { + fs afero.Fs + path string + cfg *Config + } + + type want struct { + // loaded is the Config that should be returned when Load reads the file + // back. nil means we don't check (e.g. error cases). + loaded *Config + // mode is the expected file mode after the save. 0 means we don't check. + mode os.FileMode + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "RoundTrip": { + reason: "Saving a valid Config should produce a file that Load reads back as the same Config.", + args: args{ + fs: afero.NewMemMapFs(), + path: "/c.yaml", + cfg: &Config{Version: 1, Features: Features{DisableBeta: true}}, + }, + want: want{ + loaded: &Config{Version: 1, Features: Features{DisableBeta: true}}, + mode: 0o600, + }, + }, + "CreatesParentDir": { + reason: "Save should create missing parent directories.", + args: args{ + fs: afero.NewMemMapFs(), + path: "/a/b/c/config.yaml", + cfg: &Config{Version: 1}, + }, + want: want{ + loaded: &Config{Version: 1}, + mode: 0o600, + }, + }, + "OverwritesExisting": { + reason: "Save should overwrite an existing file.", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "/c.yaml", []byte("version: 1\nfeatures:\n enableAlpha: true\n"), 0o600) + return fs + }(), + path: "/c.yaml", + cfg: &Config{Version: 1, Features: Features{DisableBeta: true}}, + }, + want: want{ + loaded: &Config{Version: 1, Features: Features{DisableBeta: true}}, + mode: 0o600, + }, + }, + "DefaultsVersion": { + reason: "Saving a Config with Version 0 should default the on-disk version to 1.", + args: args{ + fs: afero.NewMemMapFs(), + path: "/c.yaml", + cfg: &Config{}, + }, + want: want{ + loaded: &Config{Version: 1}, + mode: 0o600, + }, + }, + "EmptyPath": { + reason: "Saving to an empty path should return an error.", + args: args{ + fs: afero.NewMemMapFs(), + path: "", + cfg: &Config{Version: 1}, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := Save(tc.args.fs, tc.args.path, tc.args.cfg) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nSave(...): -want err, +got err:\n%s", tc.reason, diff) + } + if tc.want.err != nil { + return + } + + got, err := Load(tc.args.fs, tc.args.path) + if err != nil { + t.Fatalf("Load after Save returned error: %v", err) + } + if diff := cmp.Diff(tc.want.loaded, got); diff != "" { + t.Errorf("\n%s\nLoad after Save: -want, +got:\n%s", tc.reason, diff) + } + + if tc.want.mode != 0 { + info, err := tc.args.fs.Stat(tc.args.path) + if err != nil { + t.Fatalf("Stat after Save returned error: %v", err) + } + if info.Mode().Perm() != tc.want.mode { + t.Errorf("file mode: want %o, got %o", tc.want.mode, info.Mode().Perm()) + } + } + }) + } +} + +func TestResolvePath(t *testing.T) { + type args struct { + flag string + env string + xdg string + home string + } + + cases := map[string]struct { + reason string + args args + want string + }{ + "FlagWins": { + reason: "When the flag is set, it should be returned regardless of env or default.", + args: args{flag: "/flag.yaml", env: "/env.yaml", xdg: "/x", home: "/h"}, + want: "/flag.yaml", + }, + "EnvOverDefault": { + reason: "With no flag, the env var should be returned over the default.", + args: args{env: "/env.yaml", xdg: "/x", home: "/h"}, + want: "/env.yaml", + }, + "XDGDefault": { + reason: "With no flag and no env, XDG_CONFIG_HOME should drive the default.", + args: args{xdg: "/x", home: "/h"}, + want: "/x/crossplane/config.yaml", + }, + "HomeFallback": { + reason: "With no flag, no env, and no XDG, $HOME/.config should drive the default.", + args: args{home: "/h"}, + want: "/h/.config/crossplane/config.yaml", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("CROSSPLANE_CONFIG", tc.args.env) + t.Setenv("XDG_CONFIG_HOME", tc.args.xdg) + t.Setenv("HOME", tc.args.home) + got := ResolvePath(tc.args.flag) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nResolvePath(%q): -want, +got:\n%s", tc.reason, tc.args.flag, diff) + } + }) + } +} diff --git a/internal/maturity/maturity.go b/internal/maturity/maturity.go new file mode 100644 index 0000000..09d8198 --- /dev/null +++ b/internal/maturity/maturity.go @@ -0,0 +1,113 @@ +/* +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 maturity implements maturity-level gating for kong commands. +// +// Commands are tagged with `maturity:"alpha"` or `maturity:"beta"` (GA +// commands have no tag). When a maturity level is not enabled, commands +// at that level are hidden from help output but still callable. Their +// own help text is annotated with a banner indicating the level. +package maturity + +import ( + "fmt" + "strings" + + "github.com/alecthomas/kong" +) + +// Level is a command maturity level. +type Level string + +const ( + // LevelGA is the default level for stable, generally available commands. + LevelGA Level = "" + // LevelBeta marks commands that may change before becoming GA. + LevelBeta Level = "beta" + // LevelAlpha marks experimental commands that may be removed. + LevelAlpha Level = "alpha" +) + +const tagKey = "maturity" + +// Apply walks the kong model and applies maturity gating: +// - Nodes whose effective level is not in enabled are marked Hidden. +// - Every non-GA node has a banner prepended to its Help and Detail so +// invokers can tell from `--help` what maturity they are using. +// +// A node's effective level is the maturity tag on the node itself, or +// inherited from its nearest tagged ancestor. +func Apply(app *kong.Application, enabled map[Level]bool) { + enabled[LevelGA] = true + + _ = kong.Visit(app, func(node kong.Visitable, next kong.Next) error { + n, ok := node.(*kong.Node) + if !ok { + return next(nil) + } + level := effectiveLevel(n) + if !enabled[level] { + n.Hidden = true + } + if level != LevelGA { + n.Help = fmt.Sprintf("[%s] %s", strings.ToUpper(string(level)), n.Help) + n.Detail = detailForLevel(level, n.Detail) + } + return next(nil) + }) + + var detailStr string + + switch { + case enabled[LevelBeta] && enabled[LevelAlpha]: + detailStr = "Alpha and beta features are enabled. Manage enabled features with \"crossplane config set\"." + case enabled[LevelBeta]: + detailStr = "Beta features are enabled. Manage enabled features with \"crossplane config set\"." + case enabled[LevelAlpha]: + detailStr = "Alpha features are enabled. Manage enabled features with \"crossplane config set\"." + default: + detailStr = "Alpha and beta features are disabled. To enable them use \"crossplane config set\"." + } + + app.Detail += "\n\n" + detailStr +} + +// effectiveLevel returns the level configured for the node or its nearest +// ancestor that has a level. If no ancestor has a level, LevelGA is returned. +func effectiveLevel(n *kong.Node) Level { + for cur := n; cur != nil; cur = cur.Parent { + if cur.Tag == nil { + continue + } + if v := cur.Tag.Get(tagKey); v != "" { + return Level(v) + } + } + return LevelGA +} + +func detailForLevel(l Level, detail string) string { + banners := map[Level]string{ + LevelAlpha: "NOTE: Alpha features are experimental and may be changed or removed in a future release.", + LevelBeta: "NOTE: Beta features may be changed in a future release.", + } + + if b := banners[l]; b != "" { + return b + "\n\n" + detail + } + + return detail +} diff --git a/internal/maturity/maturity_test.go b/internal/maturity/maturity_test.go new file mode 100644 index 0000000..ecdb302 --- /dev/null +++ b/internal/maturity/maturity_test.go @@ -0,0 +1,160 @@ +/* +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 maturity + +import ( + "strings" + "testing" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" +) + +type stubRun struct{} + +func (stubRun) Run() error { return nil } + +type testCLI struct { + GA struct { + stubRun + } `cmd:"" help:"GA help."` + Beta struct { + stubRun + } `cmd:"" help:"Beta help." maturity:"beta"` + Alpha struct { + stubRun + } `cmd:"" help:"Alpha help." maturity:"alpha"` + Group struct { + Inner struct { + stubRun + } `cmd:"" help:"Inner help."` + } `cmd:"" help:"Group help." maturity:"alpha"` +} + +func findNode(app *kong.Application, path string) *kong.Node { + parts := strings.Split(path, "/") + cur := app.Node + for _, name := range parts { + var next *kong.Node + for _, c := range cur.Children { + if c.Name == name { + next = c + break + } + } + if next == nil { + return nil + } + cur = next + } + return cur +} + +func TestApply(t *testing.T) { + type nodeWant struct { + hidden bool + helpHas []string + helpHasNot []string + } + + type args struct { + enabled map[Level]bool + } + + type want struct { + nodes map[string]nodeWant + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "OnlyGAEnabled": { + reason: "With only GA enabled, beta and alpha commands are hidden; an alpha-tagged group hides its children too.", + args: args{ + enabled: map[Level]bool{}, + }, + want: want{ + nodes: map[string]nodeWant{ + "ga": {hidden: false}, + "beta": {hidden: true}, + "alpha": {hidden: true}, + "group": {hidden: true}, + "group/inner": {hidden: true, helpHas: []string{"ALPHA"}}, + }, + }, + }, + "BetaEnabled": { + reason: "Enabling beta unhides beta commands but leaves alpha hidden.", + args: args{ + enabled: map[Level]bool{LevelBeta: true}, + }, + want: want{ + nodes: map[string]nodeWant{ + "beta": {hidden: false}, + "alpha": {hidden: true}, + }, + }, + }, + "AllEnabledShowsBanners": { + reason: "With all levels enabled, non-GA nodes still get a banner prepended to their help; GA nodes do not.", + args: args{ + enabled: map[Level]bool{LevelAlpha: true, LevelBeta: true}, + }, + want: want{ + nodes: map[string]nodeWant{ + "ga": {hidden: false, helpHasNot: []string{"ALPHA", "BETA"}}, + "beta": {hidden: false, helpHas: []string{"BETA", "Beta help."}}, + "alpha": {hidden: false, helpHas: []string{"ALPHA", "Alpha help."}}, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p, err := kong.New(&testCLI{}, kong.Name("test"), kong.Exit(func(int) {})) + if err != nil { + t.Fatalf("kong.New: %v", err) + } + + Apply(p.Model, tc.args.enabled) + + for path, nw := range tc.want.nodes { + n := findNode(p.Model, path) + if n == nil { + t.Errorf("\n%s\nApply(...): node %q not found in model", tc.reason, path) + continue + } + if diff := cmp.Diff(nw.hidden, n.Hidden); diff != "" { + t.Errorf("\n%s\nApply(...): node %q Hidden -want, +got:\n%s", tc.reason, path, diff) + } + for _, sub := range nw.helpHas { + if !strings.Contains(n.Help, sub) { + t.Errorf("\n%s\nApply(...): node %q help should contain %q, got: %q", tc.reason, path, sub, n.Help) + } + } + for _, sub := range nw.helpHasNot { + if strings.Contains(n.Help, sub) { + t.Errorf("\n%s\nApply(...): node %q help should not contain %q, got: %q", tc.reason, path, sub, n.Help) + } + } + } + }) + } +}