Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ab8089a
feat(load): add an exact composite loader that can skip pipeline inputs
jcogilvie Nov 2, 2026
fda7fc6
feat(simulate): load and classify -f resource inputs with one rejecti…
jcogilvie Nov 2, 2026
21b4863
feat(simulate): run Composition inputs with the cluster's functions
jcogilvie Nov 2, 2026
aecfd09
feat(composition): add composition simulate
jcogilvie Nov 2, 2026
d180991
test(composition): restore the Composition cases and add a pinned-fun…
jcogilvie Nov 3, 2026
d1e4737
docs(simulate): cross-reference project and composition simulate
jcogilvie Nov 2, 2026
cb94693
fix(simulate): use only --image-configs for Composition inputs
jcogilvie Nov 2, 2026
d5ea7e8
test(composition): drop XR-only integration harness code
jcogilvie Nov 3, 2026
4c203dc
test(composition): fix stale harness docs and reject unknown case types
jcogilvie Nov 2, 2026
ad4c430
fix(simulate): say a pinned render used the changed functions
jcogilvie Nov 2, 2026
2a805d1
test(simulate): cover the per-input simulation error wrap
jcogilvie Nov 2, 2026
b03b4b4
refactor(simulate): narrow the -f input API and document directories
jcogilvie Nov 2, 2026
64a0fd3
docs(simulate): describe the Composition integration cases
jcogilvie Nov 2, 2026
91fe0e6
docs(simulate): lead the help with the permissions simulate needs
jcogilvie Nov 6, 2026
832c678
test(simtest): fail on missing fields, contradictory and double-count…
jcogilvie Nov 6, 2026
3049ba9
test(simulate): cover directory pipeline inputs and the project provi…
jcogilvie Nov 6, 2026
1e5b590
fix(simulate): reject input Compositions that share a name
jcogilvie Nov 6, 2026
7f1b931
test(simtest): don't treat unknown, sensitive or unresolved values as…
jcogilvie Nov 6, 2026
e2a7248
refactor(simulate): let each command validate its -f inputs
jcogilvie Nov 6, 2026
4c0d89b
fix(composition): take -f as a comma-separated list like resource sim…
jcogilvie Nov 6, 2026
15c0c97
refactor(simulate): have Run build the xpkg client
jcogilvie Nov 6, 2026
7639f4b
test(simtest): reject markers nested in assembled values
jcogilvie Nov 6, 2026
29d3c06
test(composition): expect -f to split on commas
jcogilvie Nov 6, 2026
2743322
test(simtest): use slices.ContainsFunc in hasMarker
jcogilvie Nov 6, 2026
3ce993b
refactor(simulate): pass diffCompositions its wrap message
jcogilvie Nov 6, 2026
85ffb47
test(simulate): drop simulateGap, fix harness comments, compile ANSI …
jcogilvie Nov 6, 2026
1df6a9a
fix(simulate): create the package client only when planning
jcogilvie Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/crossplane/composition/composition.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ type Cmd struct {
Convert convert.Cmd `cmd:"" help:"Convert a Composition to a newer version." maturity:"beta"`
Generate generateCmd `cmd:"" help:"Generate a Composition for a CompositeResourceDefinition (XRD)." maturity:"beta"`
Render xr.Cmd `cmd:"" help:"Render a composite resource (XR)."`
Simulate simulateCmd `cmd:"" help:"Preview what applying changed Compositions would do." maturity:"alpha"`
}
22 changes: 22 additions & 0 deletions cmd/crossplane/composition/help/simulate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
The `composition simulate` command previews how applying changed Compositions
would change the composite resources in a cluster. It renders each composite
resource that uses one of the Compositions with the changed version, and shows
the difference from what's in the cluster. It doesn't change the cluster.

> **Important:** Although it doesn't change the cluster, this command needs
> `create` and `patch` permission on the resources it renders. It checks each
> rendered resource with a server-side dry-run request, and Kubernetes
> authorizes a dry-run request like a real one. It also needs read access to
> the cluster's Crossplane resources, and Docker to run functions. Use `--as`,
> `--as-group`, and `--as-uid` to simulate as another user.

The command connects to the cluster in your current Kubernetes context, or the
one `--context` names.

`-f` (`--input`) names a file, or a directory of YAML files, of Compositions to
simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` reads standard input.
Every resource in them must be a Composition; to simulate a project, use
`crossplane project simulate`.
Functions come from the cluster. `--cache-dir` sets where provider package
contents are cached.

27 changes: 27 additions & 0 deletions cmd/crossplane/composition/help/simulate_examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

## Examples

Simulate a changed Composition against the current context:

```shell
crossplane composition simulate -f composition.yaml
```

Simulate two Compositions, only for composite resources in one namespace:

```shell
crossplane composition simulate -f network.yaml -f database.yaml -n team-a
```

Simulate a Composition read from standard input, for a single composite
resource:

```shell
cat composition.yaml | crossplane composition simulate -f - --name=team-a/my-database
```

Print the plan as JSON, without provider plans:

```shell
crossplane composition simulate -f composition.yaml -o json --skip-plan
```
71 changes: 71 additions & 0 deletions cmd/crossplane/composition/simulate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
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

import (
"context"

"github.com/alecthomas/kong"
un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

"github.com/crossplane/crossplane-runtime/v2/pkg/logging"

apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1"

"github.com/crossplane/cli/v2/internal/simulate"
"github.com/crossplane/cli/v2/internal/terminal"

_ "embed"
)

var (
//go:embed help/simulate.md
simulateHelp string
//go:embed help/simulate_examples.md
simulateExamples string
)

// simulateCmd previews how changed Compositions would change the composite
// resources in a cluster.
type simulateCmd struct {
simulate.Flags `embed:""`
simulate.CompositionFlags `embed:""`

Input []string `help:"Composition file or directory to simulate. Takes a comma-separated list of paths, and can be repeated. - reads standard input." placeholder:"PATH" required:"" short:"f"`
}

func (c *simulateCmd) Help() string {
return simulateHelp + simulate.Help(true) + simulateExamples
}

// Run previews the Compositions -f names against the cluster, with the
// cluster's functions.
func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error {
comps, err := simulate.LoadInputs(c.Input, func(r *un.Unstructured) error {
if r.GroupVersionKind() != apiextensionsv1.CompositionGroupVersionKind {
return simulate.RejectInput(r, "composition simulate takes Composition files")
}
return nil
})
if err != nil {
return err
}
if err := simulate.RejectDuplicateNames(comps); err != nil {
return err
}
return c.Flags.Run(sigCtx, k, logger, sp, &simulate.Inputs{Compositions: comps, Selection: &c.CompositionFlags})
}
124 changes: 124 additions & 0 deletions cmd/crossplane/composition/simulate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
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

import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/alecthomas/kong"
"github.com/google/go-cmp/cmp"

"github.com/crossplane/crossplane-runtime/v2/pkg/logging"

"github.com/crossplane/cli/v2/internal/simulate"
)

// parseSimulate parses args as `simulate` flags, running kong's validation.
func parseSimulate(t *testing.T, args ...string) (*simulateCmd, error) {
t.Helper()

var cli struct {
Simulate simulateCmd `cmd:""`
}
parser, err := kong.New(&cli, kong.Exit(func(int) {}))
if err != nil {
t.Fatalf("kong.New(...): %v", err)
}
_, err = parser.Parse(append([]string{"simulate"}, args...))
return &cli.Simulate, err
}

func TestSimulateFlags(t *testing.T) {
cases := map[string]struct {
reason string
args []string
input []string
err string
}{
"Defaults": {
reason: "With only -f the shared defaults apply.",
args: []string{"-f", "comp.yaml"},
input: []string{"comp.yaml"},
},
"RepeatedInput": {
reason: "-f repeats, splits on commas, and takes - for stdin.",
args: []string{"-f", "a,b.yaml", "--input", "-"},
input: []string{"a", "b.yaml", "-"},
},
"InputRequired": {
reason: "Without -f the command fails rather than waiting on stdin.",
err: "--input",
},
"BadName": {
reason: "A malformed --name fails CompositionFlags' Validate.",
args: []string{"-f", "comp.yaml", "--name=a/b/c"},
err: "--name",
},
"NoMaxConcurrency": {
reason: "--max-concurrency is project-only.",
args: []string{"-f", "comp.yaml", "--max-concurrency=2"},
err: "max-concurrency",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
c, err := parseSimulate(t, tc.args...)
if tc.err != "" {
if err == nil || !strings.Contains(err.Error(), tc.err) {
t.Fatalf("\n%s\nParse(%v): want error containing %q, got %v", tc.reason, tc.args, tc.err, err)
}
return
}
if err != nil {
t.Fatalf("\n%s\nParse(%v): %v", tc.reason, tc.args, err)
}
if diff := cmp.Diff(tc.input, c.Input); diff != "" {
t.Errorf("\n%s\nInput: -want, +got:\n%s", tc.reason, diff)
}
if c.Output != "plan" || c.Timeout != 2*time.Minute {
t.Errorf("\n%s\nOutput, Timeout: want plan, 2m, got %q, %v", tc.reason, c.Output, c.Timeout)
}
})
}
}

func TestSimulateHelp(t *testing.T) {
h := (&simulateCmd{}).Help()
for _, want := range []string{"composition simulate", simulate.Help(true), "## Examples"} {
if !strings.Contains(h, want) {
t.Errorf("Help(): want it to contain %q", want)
}
}
}

func TestSimulateRejectsNonComposition(t *testing.T) {
path := filepath.Join(t.TempDir(), "xr.yaml")
if err := os.WriteFile(path, []byte("apiVersion: example.org/v1\nkind: XNetwork\nmetadata:\n name: net\n"), 0o600); err != nil {
t.Fatal(err)
}
// Run loads -f before it uses k or Docker.
err := (&simulateCmd{Input: []string{path}}).Run(context.Background(), nil, logging.NewNopLogger(), nil)
want := `-f inputs include XNetwork "net" (example.org/v1); composition simulate takes Composition files`
if err == nil || err.Error() != want {
t.Errorf("Run(...): want error %q, got %v", want, err)
}
}
21 changes: 12 additions & 9 deletions cmd/crossplane/project/help/simulate.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
The `project simulate` command previews how applying a project's Compositions
would change the composite resources in a cluster. It renders each affected
composite resource and shows the difference from what's in the cluster. It
doesn't change the cluster.
doesn't change the cluster. To simulate Composition files against a cluster
without a project, use `crossplane composition simulate`.

> **Important:** Although it doesn't change the cluster, this command needs
> `create` and `patch` permission on the resources it renders. It checks each
> rendered resource with a server-side dry-run request, and Kubernetes
> authorizes a dry-run request like a real one. It also needs read access to
> the cluster's Crossplane resources, and Docker to build and run functions.
> Use `--as`, `--as-group`, and `--as-uid` to simulate as another user.

The command connects to the cluster in your current Kubernetes context, or the
one `--context` names.

The `-f` flag names the project file, `crossplane-project.yaml` by default. The
command builds the project's embedded functions and loads them into the local
Expand All @@ -12,14 +23,6 @@ the project's functions in place of the cluster's functions of the same name.
`--max-concurrency` caps the concurrent function builds, and `--cache-dir` sets
where package contents are cached.

The command connects to the cluster in your current Kubernetes context, or the
one `--context` names.

The command needs Docker, to build and run functions, and read access to the
cluster's Crossplane resources. It verifies the rendered resources with
server-side dry-run requests, so it also needs the `create` and `patch` verbs on
them. Use `--as`, `--as-group`, and `--as-uid` to simulate as another user.

For example, a run with `--skip-plan` whose project changes a Composition
prints a plan like this:

Expand Down
Loading
Loading