Appearance
Manage provider bundles
A provider bundle is the provider configuration several Clouds share: one set of Crossplane provider packages plus the one <group>/<version> those packages serve their ProviderConfig under, authored once and addressed by a stable slug. A Cloud either declares that configuration inline or names a bundle and takes both values from it. plexctl provider-bundle wraps the /v1/provider-bundles CRUD surface, the roster of Clouds one bundle configures, and the history of versions it has published.
Editing a bundle's packages or its apiVersion publishes a new immutable version. It moves no Cloud: every referencing Cloud keeps resolving the version it pins until you promote it. Rolling a package bump out is therefore two operations, and the second one is where you decide who follows and when.
Prerequisites
- An authenticated session — see Log in with plexctl.
- Authorisation on the platform singleton to create a bundle, and
manageon the bundle to update it, delete it, or read the roster of Clouds that reference it. Reading the bundle itself and its version history needs onlyobserve. The bundle catalogue is platform-wide, not per-Domain: Clouds in any Domain resolve against it. manageon each Cloud you promote. A promotion is a write on that one Cloud, so it is gated there rather than on the bundle, and a rollout can stop at a Cloud you may not write.- For the lift step, the UUID of a Cloud that declares its packages inline.
Steps
Create a bundle
shell
plexctl provider-bundle create \
--slug acme-aws-standard \
--display-name "Acme AWS standard" \
--provider aws \
--provider-package xpkg.upbound.io/upbound/provider-aws-ec2@v2.6.1 \
--provider-package xpkg.upbound.io/upbound/provider-aws-s3@v2.6.1 \
--provider-config-api-version aws.m.upbound.io/v1beta1
# SLUG DISPLAY_NAME PROVIDER PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION LATEST_VERSION ID
# acme-aws-standard Acme AWS standard aws … aws.m.upbound.io/v1beta1 1 …All five flags are required. A referencing Cloud resolves its whole provider configuration through the bundle, so there is no partial form. Repeat --provider-package once per package, between one and sixteen entries, each source named at most once. The slug and the provider are frozen after creation.
LATEST_VERSION is the newest declaration the bundle has published. A fresh bundle carries version 1, and it is the number a Cloud attaching without an explicit version pins.
Reading the bundle back immediately can answer 403. The two grants that make a new bundle readable are written by the authz-sync consumer draining the create event, not by the create itself, so retry instead of treating that denial as permanent.
Lift an inline Cloud's declaration into a bundle
lift reads a Cloud and creates a bundle carrying that Cloud's provider, package set, and ProviderConfig apiVersion, so a declaration that started on one Cloud becomes shareable:
shell
plexctl provider-bundle lift <cloud-uuid> \
--slug acme-aws-standard \
--display-name "Acme AWS standard"
# SLUG DISPLAY_NAME PROVIDER PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION LATEST_VERSION ID
# acme-aws-standard Acme AWS standard aws … aws.m.upbound.io/v1beta1 1 …The source Cloud is left untouched. Lift creates the bundle and nothing else; the next step is what points a Cloud at it.
A Cloud that already references a bundle has no inline set to lift, so the command refuses before it writes anything:
shell
plexctl provider-bundle lift <bundle-mode-cloud-uuid> \
--slug acme-aws-copy \
--display-name "Acme AWS copy"
# plexctl: provider-bundle lift: cloud acme-aws-prod already references provider bundle acme-aws-standardThe refusal is local and exits 2. No bundle is created.
Reference the bundle from a Cloud
A new Cloud enters bundle mode with --provider-bundle, which takes the bundle's slug or its UUID:
shell
plexctl cloud create \
--slug acme-aws-staging \
--display-name "Acme AWS staging" \
--provider aws \
--external-id 123456789012 \
--endpoint '{"region":"eu-central-1","partition":"aws"}' \
--region-defaults '{"default_region":"eu-central-1"}' \
--provider-bundle acme-aws-standard
# ID SLUG PROVIDER EXTERNAL_ID DISPLAY_NAME PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION PROVIDER_BUNDLE CREATED_AT
# … acme-aws-staging aws 123456789012 Acme AWS staging … aws.m.upbound.io/v1beta1 acme-aws-standard@v1 …A Cloud states its provider configuration in exactly one mode. Either --provider-bundle names a bundle, or the --provider-package / --provider-config-api-version pair declares the configuration inline. Mixing the two, or naming neither, is refused locally with exit 2. The PROVIDER_PACKAGES and PROVIDER_CONFIG_API_VERSION cells above are the values of the pinned version, resolved on every read; PROVIDER_BUNDLE names where they came from as <slug>@v<number> and stays empty for an inline Cloud.
The attach pinned version 1 because --provider-bundle-version was omitted, which pins the bundle's latest. State it to pin another published version instead:
shell
plexctl cloud create \
--slug acme-aws-staging \
--display-name "Acme AWS staging" \
--provider aws \
--external-id 123456789012 \
--endpoint '{"region":"eu-central-1","partition":"aws"}' \
--region-defaults '{"default_region":"eu-central-1"}' \
--provider-bundle acme-aws-standard \
--provider-bundle-version 2The flag requires --provider-bundle: an inline Cloud pins no bundle, so there is no version for it to name, and naming it alone exits 2.
An existing inline Cloud moves into bundle mode with the same flag on update:
shell
plexctl cloud update <cloud-uuid> --provider-bundle acme-aws-standard
# ID SLUG PROVIDER EXTERNAL_ID DISPLAY_NAME PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION PROVIDER_BUNDLE CREATED_AT
# … acme-aws-prod aws 123456789012 Acme AWS production … aws.m.upbound.io/v1beta1 acme-aws-standard@v1 …The way back is the full inline pair in one patch, which restores the Cloud's own declaration and clears the reference:
shell
plexctl cloud update <cloud-uuid> \
--provider-package xpkg.upbound.io/upbound/provider-aws-ec2@v2.6.1 \
--provider-package xpkg.upbound.io/upbound/provider-aws-s3@v2.6.1 \
--provider-config-api-version aws.m.upbound.io/v1beta1
# ID SLUG PROVIDER EXTERNAL_ID DISPLAY_NAME PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION PROVIDER_BUNDLE CREATED_AT
# … acme-aws-prod aws 123456789012 Acme AWS production … aws.m.upbound.io/v1beta1 …A patch that names only one half of the pair leaves the Cloud with neither a package set nor an apiVersion of its own, and is rejected. Leaving bundle mode clears the version pin along with the reference.
Read the blast radius
list-clouds names every Cloud that takes its provider configuration from the bundle:
shell
plexctl provider-bundle list-clouds acme-aws-standard --all
# ID SLUG DISPLAY_NAME VERSION
# … acme-aws-prod Acme AWS production 1
# … acme-aws-staging Acme AWS staging 1This is the set the server counts when it refuses a delete, and it is served whole rather than filtered per Cloud, so the count you see is the count the delete enforces — which is also why reading it needs manage on the bundle rather than the observe that reads the bundle itself. Use --all: without it the command prints one page, and the text output carries nothing that would tell you the roster continued past it.
VERSION is the declaration each Cloud pins. Rows on one page may differ in it once a promotion has covered some of the roster and not the rest, and this is the output that tells you which Clouds a further stage still has to reach. The console shows the same roster on the Clouds tab of the bundle's detail page, reached from Provider bundles in the Cloud section of the sidebar. The tab carries the same requirement, so it is only shown to a caller holding manage; an observe-only reader sees the bundle's Overview and its Versions tab without it.
Publish a new version
shell
plexctl provider-bundle update acme-aws-standard \
--provider-package xpkg.upbound.io/upbound/provider-aws-ec2@v2.7.0 \
--provider-package xpkg.upbound.io/upbound/provider-aws-s3@v2.7.0
# SLUG DISPLAY_NAME PROVIDER PROVIDER_PACKAGES PROVIDER_CONFIG_API_VERSION LATEST_VERSION ID
# acme-aws-standard Acme AWS standard aws … aws.m.upbound.io/v1beta1 2 …LATEST_VERSION is the number this edit published. No Cloud has moved onto it: every Cloud the previous step listed still pins version 1 and still converges with version 1's packages. Re-run list-clouds after the edit and the VERSION column is unchanged.
Both content flags publish. --provider-package replaces the whole declared set, so restate every package the bundle should keep; --provider-config-api-version patches on its own, and a patch naming both publishes one version rather than two. --display-name on its own publishes nothing, because a rename authors no declaration. There is no --slug and no --provider: both are frozen, and the server rejects a patch that carries either.
The console publishes the same way: Edit on the bundle's detail page saves the packages and the apiVersion, and the dialog states that the save publishes a version and moves no Cloud. It sends only the fields you actually changed, so a rename there publishes nothing either — the dialog keeps Save disabled until something moves.
Read the published history
shell
plexctl provider-bundle versions acme-aws-standard --all
# VERSION CREATED PACKAGES
# 2 2026-08-10T18:04:11Z 2
# 1 2026-08-03T09:12:40Z 2Newest first. The row counts the packages; --output json carries each version whole: every package with its source and version, the ProviderConfig apiVersion, and the subject that published it. That is what to compare two versions in before you pick the one to promote onto:
shell
plexctl provider-bundle versions acme-aws-standard --output json \
| jq '.items[] | {version, provider_packages}'Reading the history needs only observe on the bundle, the permission that reads the bundle itself. The console shows it on the Versions tab of the bundle's detail page, which spells out each version's whole package set rather than counting it.
Promote in stages
promote moves referencing Clouds onto one published version. Name a first subset with --cloud, repeated once per Cloud:
shell
plexctl provider-bundle promote acme-aws-standard \
--to-version 2 \
--cloud <staging-cloud-uuid>
# <staging-cloud-uuid>: promoted to version 2Each target is a separate PATCH /v1/clouds/{id} on that Cloud's version pin, and the outcome line is printed as it settles. Watch the stage (the next step says what to watch), then widen it, or stop. Halting a rollout means not issuing the next command: nothing is queued, and the Clouds you promoted stay where they are.
When the stage has held, take the rest of the roster:
shell
plexctl provider-bundle promote acme-aws-standard \
--to-version 2 \
--all-clouds
# <prod-cloud-uuid>: promoted to version 2
# <staging-cloud-uuid>: promoted to version 2--all-clouds drains the whole roster rather than its first page, and promoting a Cloud that already pins the target version is a no-op write rather than an error. A per-Cloud failure does not stop the walk: the line names the Cloud and the reason, the remaining targets are still attempted, and the command exits 1 so a partial rollout is not read as a complete one. Re-run it against the Clouds that failed once the reason is gone.
Rolling back is the same command pointed at an earlier version:
shell
plexctl provider-bundle promote acme-aws-standard \
--to-version 1 \
--cloud <staging-cloud-uuid>A promotion moves the pin and writes nothing about the package overrides a Cloud carries, so a Cloud running one package at its own version keeps running it after the stage. What the override means can change: if the newly pinned version no longer carries that source, the deviation shadows nothing and every read reports it as an addition under the origin each package carries in plexctl cloud get <cloud-uuid> --output json. The promotion does not refuse, so that classification is the only signal. Drop such an override by restating the set without it, or clear the set with plexctl cloud update <cloud-uuid> --clear-package-overrides; rolling the pin back to a version that does carry the source makes the same entry a shadow again.
Exactly one of --cloud and --all-clouds must be set, and --to-version must be at least 1. Naming neither, naming both, or naming a version below 1 exits 2 before any request is issued. A bundle no Cloud references prints No Cloud references provider bundle acme-aws-standard. and exits 0.
The console offers the same two moves on the Clouds tab: Promote on a row that is behind pins that one Cloud to the latest version, and "Promote all to v2" takes the whole roster the way --all-clouds does. It loads the roster pages the table has not shown yet, then walks every Cloud that is behind, one at a time, reporting how many moved and which did not. A roster page it cannot read stops it before the first write: nothing is promoted and the panel says so, rather than moving the part of the roster it could see. A row already on the latest version reads Latest instead of offering a write.
Watch the installs while a stage serves
A promoted Cloud converges on the next fleet sweep of each management cluster hosting one of its Projects. The Cloud Assignment reads are where that lands: the PROVIDER column folds the assignment's provider_installs entries to the most severe phase present, so it reports the package that blocks provisioning rather than the healthiest one.
shell
plexctl cloud assignment list --project-id <project-uuid>
# ID PROJECT_ID CLOUD_ID STATE MATERIALISED PROVIDER CREATED_AT
# … … … approved true Serving …Serving on every affected assignment is the stage holding. Read the per-package detail with --output json, where each entry carries its own source and the message behind a Failed or Conflict phase. Widen the rollout only once the stage you promoted reports what you expected.
Align stages with management clusters
A management cluster installs one Crossplane Provider object per package source. If a rollout stage splits one source across two versions on Clouds whose Projects share a management cluster, the fleet has two versions for one object and refuses: it reports the Conflict phase on every affected Cloud and applies nothing, leaving whatever is already installed running. That is deliberate. Converging to the higher version would change the controller under a Project that asked for the other one, and neither version is the predictable winner.
Cut your stages along management-cluster boundaries, so every Cloud whose Projects land on one cluster moves in the same stage. A Conflict phase after a promotion is the signal that a stage crossed a boundary; the message names both versions, and promoting the rest of that cluster's Clouds, or rolling the stage back, resolves it.
A package override splits a source the same way, without a promotion taking part. What the fleet installs is each Cloud's effective set, so a Cloud whose override runs one source at v2.7.1 beside a Cloud on the pinned v2.6.1 gives that shared cluster two versions of one Provider object, and both Clouds report the same Conflict phase with both versions in the message. Two Clouds on the same pin can disagree this way, and two Clouds on different pins can agree once an override has lined their effective versions up, so the pins alone no longer say whether a cluster is consistent. Read each Cloud with plexctl cloud get <cloud-uuid> --output json: it reports the effective version per package and the origin that put it there. Resolve the conflict by giving the other Clouds on that cluster the same effective version, or by dropping the override.
Delete a bundle
shell
plexctl provider-bundle delete acme-aws-standard --yes
# plexctl: Provider Bundle Referenced (provider_bundle_referenced): providerbundles DeleteProviderBundle: the provider bundle is still referenced by 2 Clouds (correlation_id=… referencing_clouds=2)The refusal carries the number of Clouds that still reference the bundle, so the size of the re-pointing job is known before the retry. Move each Cloud the roster names back to inline mode or onto another bundle, then repeat the delete. A delete the server accepts prints nothing and exits 0.
Verification
Read a referencing Cloud back and confirm it reports the pinned version's values together with the bundle and version they came from:
shell
plexctl cloud get <cloud-uuid> --output json \
| jq '{provider_bundle_slug, provider_bundle_version, provider_packages}'
# {
# "provider_bundle_slug": "acme-aws-standard",
# "provider_bundle_version": 2,
# "provider_packages": [
# {"source": "xpkg.upbound.io/upbound/provider-aws-ec2", "version": "v2.7.0"},
# {"source": "xpkg.upbound.io/upbound/provider-aws-s3", "version": "v2.7.0"}
# ]
# }provider_bundle_slug and provider_bundle_version are both absent for a Cloud in inline mode, so their presence is the signal that the reference took. provider_packages is the pinned version's set, resolved on every read, so a Cloud still on version 1 reports version 1's packages however many versions the bundle has published since.
See also
../../reference/cli/plexctl/provider-bundle.md— every flag and exit code.../../reference/api/provider-bundles.md— the HTTP contract, the ReBAC gates, and the error taxonomy.- Manage Clouds — the Cloud aggregate and its inline mode.
../../contexts/provisioning/cloud.md— the aggregate, its invariants, and how a bundle-mode Cloud resolves its effective configuration.