Skip to content

feat(resource): add crossplane resource simulate with provider diff servers (alpha) - #429

Draft
haarchri wants to merge 2 commits into
crossplane:mainfrom
haarchri:feature/resource-simulate
Draft

haarchri wants to merge 2 commits into
crossplane:mainfrom
haarchri:feature/resource-simulate

Conversation

@haarchri

Copy link
Copy Markdown
Member

Description of your changes

Implements crossplane resource simulate per the accepted design(design/one-pager-cli-simulate.md): given YAML for one or more managed resources (-f), preview what applying them would do, provider-computed, end to end.

For each input resource the CLI fetches the live MR from the current kubeconfig context, runs the providers own package image locally as a diff server (the gRPC PlanService upjet providers now serve via internal diff-server,
merged in crossplane/upjet#765, crossplane/upjet#769, crossplane/upjet#778), and renders the providers verdict: create, update, no-op or the replace nobody spotted in review, with the unsynced-until-replaced warning. Replaces are counted separately in the summary and never presented as destroy-and-recreate.

Resources without a diff server (no DiffServer capability, no route, or the server refuses the resource) degrade to an honest client-side diff marked [?] with the approximate warning and nothing fails the run, nothing silently drops
out.

$ crossplane resource simulate -f resources.yaml
[-/+] VPC.ec2.aws.upbound.io import-vpc (replace)
    spec:
      forProvider:
        ~ cidrBlock: "10.0.0.0/16" -> "10.1.0.0/16"  # forces replacement

Plan: 0 to add, 0 to change, 0 to destroy, 1 requires replacement.

Warning: 1 resource has fields that force replacement: cidrBlock.
Crossplane providers never destroy and recreate to apply a change.
Applying this leaves the resource unsynced until it is replaced manually.

The design one-pager predated the merged upjet protocol

  • The provider subcommand is internal diff-server and the package capability is DiffServer (spec.capabilities); the gRPC service remains PlanService (proto/diff/v1alpha1).
  • GetInfo was dropped: routing is built client-side from the CRDs inside each provider package, by group and kind, and the capability is checked before any container starts.
  • Transport is decided: plaintext gRPC on an ephemeral localhost port, one resource per unary Plan request.
  • No diagnostics in responses; failures are gRPC statuses, with FAILED_PRECONDITION/DIFF_COMPUTATION_NOT_SUPPORTED mapping to the [?] fallback.
  • The request's kubernetes_object_store carries the referenced ProviderConfig, fetched with the user's own kubeconfig and RBAC (the server is offline and sees only what travels in the request; credentials Secrets deliberately stay out today and the server plans with placeholder, but we need this as some providers e.g. gcp uses fields like projectIds).

Groundwork for project simulate (#367)

This PR deliberately builds the shared pieces project simulate must reuse, so the two commands converge on one implementation and we need to sync in a followup PR:

  • internal/plan the typed per-resource result (actions, field changes with sensitive/known-after-apply/unresolved markers, replace paths), the diff server client, and the output renderers (-o plan|json|yaml, versioned JSON/YAML envelope with a top-level errored gate for CI).
  • internal/plan.Fallback theclient-side diff ([?] path). Schema-less by design; expected to be subsumed by crossplane-diff's calculator when its machinery moves in with Add crossplane project simulate #367. -o diff is intentionally not implemented here: crossplane-diff renders real full-manifest unified diffs today, and that renderer is what -o diff should become, not a lower-fidelity imitation.
  • internal/diffserver capability gate, CRD-based routing table, container lifecycle with deterministic digest-derived names and --keep-diff-servers reuse eg. feat(render): label docker containers and networks created by render #407

Implementation notes

  • resource simulate enters the tree as alpha; provider images come from the providers installed on the cluster by default, --provider-images overrides.
  • internal/docker gains a published-port option + host-port lookup, the same mechanism render's function runtime uses, lifted from its private code into the shared helpers.
  • Shared spinner fix: attach a TTY stdin so bubbletea's terminal capability queries are consumed instead of echoing as escape sequences (affected all spinner-using commands on modern terminals).
  • Nix: nixpkgs-unstable bump (upjet requires Go 1.26.8) and new Go vendor hash.

Fixes #368
Implements the resource simulate half of #369 (project simulate planning, --all provider upgrade preview, and --skip-plan can followup with a new PR

I have:

Need help with this checklist? See the cheat sheet.

Tested

kubectl get vpc -n crossplane-system   import-vpc  -o yaml           
apiVersion: ec2.aws.m.upbound.io/v1beta1
kind: VPC
metadata:
  annotations:
    crossplane.io/external-create-pending: "2026-10-10T09:15:10Z"
    crossplane.io/external-create-succeeded: "2026-10-10T09:15:10Z"
    crossplane.io/external-name: vpc-085565581159bc779
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"ec2.aws.m.upbound.io/v1beta1","kind":"VPC","metadata":{"annotations":{},"name":"import-vpc","namespace":"crossplane-system"},"spec":{"forProvider":{"cidrBlock":"192.168.0.0/16","enableDnsHostnames":true,"enableDnsSupport":true,"instanceTenancy":"default","region":"us-west-2"},"providerConfigRef":{"kind":"ProviderConfig","name":"default"}}}
  creationTimestamp: "2026-08-18T09:18:14Z"
  finalizers:
  - finalizer.managedresource.crossplane.io
  generation: 2
  name: import-vpc
  namespace: crossplane-system
  resourceVersion: "15716717"
  uid: 13fd3e18-3a84-49c9-9b04-8ef33ea536cf
spec:
  forProvider:
    cidrBlock: 192.168.0.0/16
    enableDnsHostnames: true
    enableDnsSupport: true
    instanceTenancy: default
    region: us-west-2
    tags:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
  initProvider: {}
  managementPolicies:
  - '*'
  providerConfigRef:
    kind: ProviderConfig
    name: default
status:
  atProvider:
    arn: arn:aws:ec2:us-west-2:609897127049:vpc/vpc-085565581159bc779
    assignGeneratedIpv6CidrBlock: false
    cidrBlock: 192.168.0.0/16
    defaultNetworkAclId: acl-0855de58a9e6406f3
    defaultRouteTableId: rtb-04442fca48b8b3403
    defaultSecurityGroupId: sg-021d3919605e86965
    dhcpOptionsId: dopt-037cbc88bae6bb04c
    enableDnsHostnames: true
    enableDnsSupport: true
    enableNetworkAddressUsageMetrics: false
    id: vpc-085565581159bc779
    instanceTenancy: default
    ipv6AssociationId: ""
    ipv6CidrBlock: ""
    ipv6CidrBlockNetworkBorderGroup: ""
    ipv6IpamPoolId: ""
    ipv6NetmaskLength: 0
    mainRouteTableId: rtb-04442fca48b8b3403
    ownerId: "609897127049"
    region: us-west-2
    tags:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
    tagsAll:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
  conditions:
  - lastTransitionTime: "2026-10-10T09:15:15Z"
    reason: Available
    status: "True"
    type: Ready
  - lastTransitionTime: "2026-08-18T09:18:14Z"
    observedGeneration: 2
    reason: ReconcileSuccess
    status: "True"
    type: Synced
  - lastTransitionTime: "2026-08-18T09:18:16Z"
    reason: Success
    status: "True"
    type: LastAsyncOperation
cat aa.yaml 
apiVersion: ec2.aws.m.upbound.io/v1beta1
kind: VPC
metadata:
  annotations:
    crossplane.io/external-create-pending: "2026-10-10T09:15:10Z"
    crossplane.io/external-create-succeeded: "2026-10-10T09:15:10Z"
    crossplane.io/external-name: vpc-085565581159bc779
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"ec2.aws.m.upbound.io/v1beta1","kind":"VPC","metadata":{"annotations":{},"name":"import-vpc","namespace":"crossplane-system"},"spec":{"forProvider":{"cidrBlock":"192.168.0.0/16","enableDnsHostnames":true,"enableDnsSupport":true,"instanceTenancy":"default","region":"us-west-2"},"providerConfigRef":{"kind":"ProviderConfig","name":"default"}}}
  creationTimestamp: "2026-08-18T09:18:14Z"
  finalizers:
  - finalizer.managedresource.crossplane.io
  generation: 2
  name: import-vpc
  namespace: crossplane-system
  resourceVersion: "15716717"
  uid: 13fd3e18-3a84-49c9-9b04-8ef33ea536cf
spec:
  forProvider:
    cidrBlock: 10.10.0.0/16
    enableDnsHostnames: true
    enableDnsSupport: true
    instanceTenancy: default
    region: us-west-2
    tags:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
  initProvider: {}
  managementPolicies:
  - '*'
  providerConfigRef:
    kind: ProviderConfig
    name: default
status:
  atProvider:
    arn: arn:aws:ec2:us-west-2:609897127049:vpc/vpc-085565581159bc779
    assignGeneratedIpv6CidrBlock: false
    cidrBlock: 192.168.0.0/16
    defaultNetworkAclId: acl-0855de58a9e6406f3
    defaultRouteTableId: rtb-04442fca48b8b3403
    defaultSecurityGroupId: sg-021d3919605e86965
    dhcpOptionsId: dopt-037cbc88bae6bb04c
    enableDnsHostnames: true
    enableDnsSupport: true
    enableNetworkAddressUsageMetrics: false
    id: vpc-085565581159bc779
    instanceTenancy: default
    ipv6AssociationId: ""
    ipv6CidrBlock: ""
    ipv6CidrBlockNetworkBorderGroup: ""
    ipv6IpamPoolId: ""
    ipv6NetmaskLength: 0
    mainRouteTableId: rtb-04442fca48b8b3403
    ownerId: "609897127049"
    region: us-west-2
    tags:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
    tagsAll:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
  conditions:
  - lastTransitionTime: "2026-10-10T09:15:15Z"
    reason: Available
    status: "True"
    type: Ready
  - lastTransitionTime: "2026-08-18T09:18:14Z"
    observedGeneration: 2
    reason: ReconcileSuccess
    status: "True"
    type: Synced
  - lastTransitionTime: "2026-08-18T09:18:16Z"
    reason: Success
    status: "True"
    type: LastAsyncOperation
---
apiVersion: ec2.aws.m.upbound.io/v1beta1
kind: VPC
metadata:
  name: test123
  namespace: crossplane-system
spec:
  forProvider:
    cidrBlock: 10.10.0.0/16
    enableDnsHostnames: true
    enableDnsSupport: true
    instanceTenancy: default
    region: eu-central-1
    tags:
      crossplane-kind: vpc.ec2.aws.m.upbound.io
      crossplane-name: import-vpc
      crossplane-providerconfig: default
  initProvider: {}
  managementPolicies:
  - '*'
  providerConfigRef:
    kind: ProviderConfig
    name: default

crossplane resource simulate -f aa.yaml --provider-images=xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0
✓ Starting provider diff servers
[-/+] VPC.ec2.aws.m.upbound.io crossplane-system/import-vpc (replace)
    spec:
      forProvider:
        ~ cidrBlock: "192.168.0.0/16" -> "10.10.0.0/16"  # forces replacement

[+] VPC.ec2.aws.m.upbound.io crossplane-system/test123 (create)
    spec:
      forProvider:
        + cidrBlock: "10.10.0.0/16"
        + enableDnsHostnames: "true"
        + enableDnsSupport: "true"
        + instanceTenancy: "default"
        + region: "eu-central-1"
        tags:
          + crossplane-kind: "vpc.ec2.aws.m.upbound.io"
          + crossplane-name: "import-vpc"
          + crossplane-providerconfig: "default"
        tagsAll:
          + crossplane-kind: "vpc.ec2.aws.m.upbound.io"
          + crossplane-name: "import-vpc"
          + crossplane-providerconfig: "default"

Plan: 1 to add, 0 to change, 0 to destroy, 1 requires replacement.

Warning: 1 resource has fields that force replacement: cidrBlock.
Crossplane providers never destroy and recreate to apply a change.
Applying this leaves the resource unsynced until it is replaced manually.
crossplane resource simulate -f aa.yaml --provider-images=xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0 -o json 
✓ Starting provider diff servers
{
  "formatVersion": "0.1",
  "errored": false,
  "summary": {
    "create": 1,
    "update": 0,
    "destroy": 0,
    "replace": 1,
    "noop": 0,
    "approximate": 0,
    "errored": 0
  },
  "plans": [
    {
      "apiVersion": "ec2.aws.m.upbound.io/v1beta1",
      "kind": "VPC",
      "name": "import-vpc",
      "namespace": "crossplane-system",
      "action": "replace",
      "changes": [
        {
          "path": "spec.forProvider.cidrBlock",
          "actual": {
            "value": "192.168.0.0/16"
          },
          "planned": {
            "value": "10.10.0.0/16"
          },
          "requiresReplace": true,
          "origin": "desired"
        }
      ],
      "replacePaths": [
        "spec.forProvider.cidrBlock"
      ]
    },
    {
      "apiVersion": "ec2.aws.m.upbound.io/v1beta1",
      "kind": "VPC",
      "name": "test123",
      "namespace": "crossplane-system",
      "action": "create",
      "changes": [
        {
          "path": "spec.forProvider.cidrBlock",
          "planned": {
            "value": "10.10.0.0/16"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.enableDnsHostnames",
          "planned": {
            "value": "true"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.enableDnsSupport",
          "planned": {
            "value": "true"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.instanceTenancy",
          "planned": {
            "value": "default"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.region",
          "planned": {
            "value": "eu-central-1"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.tags.crossplane-kind",
          "planned": {
            "value": "vpc.ec2.aws.m.upbound.io"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.tags.crossplane-name",
          "planned": {
            "value": "import-vpc"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.tags.crossplane-providerconfig",
          "planned": {
            "value": "default"
          },
          "origin": "desired"
        },
        {
          "path": "spec.forProvider.tagsAll.crossplane-kind",
          "planned": {
            "value": "vpc.ec2.aws.m.upbound.io"
          },
          "origin": "provider"
        },
        {
          "path": "spec.forProvider.tagsAll.crossplane-name",
          "planned": {
            "value": "import-vpc"
          },
          "origin": "provider"
        },
        {
          "path": "spec.forProvider.tagsAll.crossplane-providerconfig",
          "planned": {
            "value": "default"
          },
          "origin": "provider"
        }
      ]
    }
  ]
}

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Comment thread internal/diffserver/manager.go

@adamwg adamwg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't made it all the way through this yet, but leaving some initial thoughts. I think the spinner change may be unnecessary after #406 is merged.

Comment on lines +36 to +48
Examples:

# Preview what applying resources.yaml would do.
crossplane resource simulate -f resources.yaml

# Preview against a specific provider version instead of the installed one.
crossplane resource simulate -f resources.yaml --provider-images xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0

# Keep diff servers running for faster subsequent runs.
crossplane resource simulate -f resources.yaml --keep-diff-servers

# Emit machine-readable results for CI gating.
crossplane resource simulate -f resources.yaml -o json

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we should format these to match what we have in other commands:

Suggested change
Examples:
# Preview what applying resources.yaml would do.
crossplane resource simulate -f resources.yaml
# Preview against a specific provider version instead of the installed one.
crossplane resource simulate -f resources.yaml --provider-images xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0
# Keep diff servers running for faster subsequent runs.
crossplane resource simulate -f resources.yaml --keep-diff-servers
# Emit machine-readable results for CI gating.
crossplane resource simulate -f resources.yaml -o json
## Examples
Preview what applying resources.yaml would do:
```shell
crossplane resource simulate -f resources.yaml
```
Preview against a specific provider version instead of the installed one:
```shell
crossplane resource simulate -f resources.yaml --provider-images xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0
```
Keep diff servers running for faster subsequent runs:
```shell
crossplane resource simulate -f resources.yaml --keep-diff-servers
```
Emit machine-readable results for CI gating:
```shell
crossplane resource simulate -f resources.yaml -o json
```


images := c.ProviderImages
if len(images) == 0 {
if images, err = installedProviderImages(ctx, kubeClient); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than defaulting to all the installed providers, could we find only the providers that are relevant to the resources? I believe we can do that by finding the relevant CRDs in the cluster and following their owner refs.

Comment on lines +259 to +260
// The provider config lives in the provider family's group, one label
// above the resource's group: ec2.aws.m.upbound.io -> aws.m.upbound.io.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't true for all providers (e.g., provider-kubernetes). But, I'm not sure how we can reliably determine the right group, aside from asking the provider.

for _, p := range providers.Items {
img := p.Status.CurrentIdentifier
if img == "" {
img = p.Spec.Package

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we will want a way to apply ImageConfig rewrites to these images. For crossplane project simulate obviously we can use what's in the project metadata. For resource simluate maybe we should have a flag. Another option would be to use the p.Status.ResolvedPackage, but that assumes the same rewrites used in the cluster make sense locally, which I expect is often not true.

Comment on lines +322 to +327
if kubeconfig.QPS == 0 {
kubeconfig.QPS = 20
}
if kubeconfig.Burst == 0 {
kubeconfig.Burst = 30
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth a comment explaining why we set these values.

Comment thread internal/diffserver/manager.go
id, err = m.rt.StartContainer(ctx, name, image,
// --debug precedes the subcommand: it is the provider's global flag.
docker.StartWithCommand([]string{"--debug", "internal", "diff-server", "--address", fmt.Sprintf(":%d", containerPort)}),
docker.StartWithPublishedPort(netip.MustParseAddr("127.0.0.1"), containerPort),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need a way to override this, like we have in render with the runtime-docker-publish-address annotation. Fine to leave it hard-coded for the first version, but given the esoteric Docker setups we see from users I expect it's something we'll have to figure out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add crossplane resource simulate

2 participants