Skip to content

Migration and adoption

This page carries every breaking migration this repository has published. Read the section for the boundary you are crossing. Migrating to 2.0 is the current one; Migrating to 1.0 is retained because the adoption and cross-cluster consumption procedures below are still the supported ones.

Migrating to 2.0

2.0 changes one request kind: GrafanaProvisioningRepository. Every other request API is unaffected, and no 1.x request of any other kind needs an edit.

GrafanaProvisioningRepository provider settings moved into a typed block

1.x accepted exactly one repository shape: url, branch and path were required directly on spec.repository.

spec:
  repository:
    uid: example-application-overview
    title: Example application overview
    url: https://github.com/example/grafana-dashboards
    branch: main
    path: application-overview
    connectionRef:
      name: existing-grafana-connection

2.0 accepts six provider shapes, so those three fields moved into a block named by a required type discriminator. spec.repository.required is now uid, title, type, connectionRef, sync and workflows. The request above becomes:

spec:
  repository:
    uid: example-application-overview
    title: Example application overview
    type: github
    github:
      url: https://github.com/example/grafana-dashboards
      branch: main
      path: application-overview
    connectionRef:
      name: existing-grafana-connection
    sync:
      enabled: true
      target: folder
      intervalSeconds: 60
    # Empty means this Git-provisioned subtree is read-only from Grafana.
    workflows: []

type accepts local, github, githubEnterprise, git, bitbucket and gitlab. A fail-closed admission rule requires exactly the block matching type and refuses every other provider block, so a request naming github while carrying a gitlab block is rejected rather than silently pruned.

Outcome: rejected. Every 1.x request is refused at admission until it is restructured, because url, branch and path no longer exist on spec.repository and type, sync and workflows are all required.

sync and workflows are required, not defaulted

1.x hard-coded the sync block in the renderer and emitted no change workflows at all. 2.0 takes both from the request and supplies no default, so both keys are required.

sync requires enabled, target and intervalSeconds together. sync.target accepts folder and folderless; instance is present in the schema enum but refused by a fail-closed admission rule, because an instance-target repository claims the whole instance rather than one folder subtree, which is the two-owner conflict GCV-0023's decision exists to prevent.

workflows items are write and branch. An explicit empty list keeps the subtree read-only from Grafana, which is the behaviour 1.x had with no way to express anything else.

Outcome: rejected if either key is omitted.

stackRef.name and repository.uid are immutable

Both were mutable in 1.x. In 2.0 an update that changes either is refused.

The guard closes a retargeting path: repository.uid and the stack reference together are the external identity of an already-created Grafana repository, so changing one on an existing request pointed a live external resource at a different stack or a different repository rather than creating a new one. Outcome: rejected on update. An existing object retains its current values without an edit; to change either, delete the request and create a new one under the decommission contract.

secure fields take a name, never a credential

The create form of every secure.<key> field is unrepresentable, on this kind and on GrafanaProvisioningConnection. A credential reaches Grafana only as a secure value the composition itself creates from a Kubernetes Secret, referenced by name. HTTPS URLs also reject userinfo, so a credential cannot be smuggled through https://user:token@host. Outcome: rejected for any request that carried a credential literal; no 1.x request could have, because the field did not exist.

Verification after the 2.0 migration

Apply each restructured request and confirm it is admitted, then confirm the rendered RepositoryV0Alpha1 carries the provider block you selected. A request refused at admission names the exact rule that refused it.

Migrating to 1.0

This section compares the explicit pre-breaking checkout baseline 85c4344a5146eea98b4bfa9fb1c110858cd1f152 with the 1.0 candidate's platform/apis/ schemas. That baseline is an unreleased 0.x checkout, not a v0.1.0 tag or a published release: neither exists. The parent of the first breaking commit already contains the multi-organization schema seam, so this comparison deliberately includes it rather than treating that seam as a compatible starting point.

The 1.0 candidate remains held by owner decision. The original admission defects are historical: the agent-observability collectionRefs set-list items now have the required atomic map declaration, and every XRD installs into the pinned real API-server harness.

The explicit-null boundary is measured separately. Under the shipped nullable: true schema, Kubernetes persists spec.scim: null with the key present. CEL has() does not see that null-valued field, so admission accepts it. The renderer sees the persisted key, refuses the request and emits no children. Omitted scim stays absent. This reconcile-time fail-closed boundary was accepted by the owner on 2026-09-08; it is not an unresolved schema repair or supported SCIM configuration.

The owner holds 1.0 until GCV-0061 lands. Release-please PR #32 remains the release candidate; feature delivery does not itself authorize merging that PR, creating a release, or creating a Git tag. The steps below describe migration to the unreleased current API.

The guide uses these outcomes precisely:

  • Rejected means the current schema or request renderer refuses a create, apply, or reconciliation until the request is changed.
  • Silently changed means the old schema accepted an input but pruned it; it never represented an active request setting.
  • Unaffected means an existing object can retain its old shape. An added API kind has no existing object to migrate.

Migration order

  1. Inventory GrafanaCloudStackRequest objects and the platform organization registry before applying the new XRDs.
  2. Choose one registered organization key for every existing stack request and add it in a reviewed change. The key must permit that request's existing region and usage.
  3. Remove any attempted spec.scim input. Use the supported external-group mapping APIs instead.
  4. Decide whether the old stack must be replaced to gain retention or expiry. Those choices cannot be added after creation.
  5. Apply the new XRDs, then re-apply each migrated request and check the resulting output-document location. It is now organization-segmented.

This inert shape illustrates the required addition; example-primary is a placeholder registry key, not a deployable identity.

apiVersion: platform.example.org/v1beta1
kind: GrafanaCloudStackRequest
metadata:
  name: examplestack01
  namespace: example-vending
spec:
  displayName: Example stack
  slug: examplestack01
  region: prod-us-central-0
  usage: development
  organization: example-primary

Existing request kinds

The baseline already exposed GrafanaCloudStackRequest, GrafanaCustomRoleBinding, GrafanaTeamAccess, and GrafanaContentAccessPolicy. The custom-role definition moved from the old combined stack file into its own file, but its kind and schema are unchanged. GrafanaTeamAccess and GrafanaContentAccessPolicy are also unchanged, so their existing requests are unaffected.

Stack request changes

ChangeBefore shape1.0 shape and adopter actionExisting 0.x request outcome
Organizationspec required displayName, slug, region, and usage.spec.organization is additionally required, is 1-63 lowercase DNS-label characters, and is immutable. Add a permitted registry key before the next reconciliation; changing that key later requires a replacement request.Rejected while absent: the current schema requires it and the renderer also refuses a stack claim without it.
Output identity consequenceOutput identity was based on {prefix}/{region}/{usage}/{slug}.The organization requirement makes the output identity {prefix}/{organization}/{usage}/{slug}. Review external-secret access and consumers after migration.Unaffected until the reviewed organization migration is applied; that deliberate update then selects a different output location.
Product switchesNo products object.Optional spec.products.applicationObservability, kubernetesObservability, and databaseObservability, each defaulting to false. Set only the switches the platform has approved.Unaffected when omitted; all three default to disabled.
SCIM compatibility inputNo declared spec.scim; an unknown field was pruned by the structural schema.spec.scim is preserved for the intended presence rejection, !has(self.scim). It is not supported configuration. Remove it and use external-group mappings.The old attempted input was silently changed by pruning. Non-null input is rejected. Explicit null is admitted and persisted with its key present, then rejected at reconcile with zero children. This is the accepted fail-closed boundary, not supported SCIM configuration.
RetentionNo retention selection.Optional spec.retention, but if selected it requires class. Its presence cannot be added or removed after creation and the value is immutable. Choose it only for a newly created replacement request.Unaffected when omitted. An existing request cannot be updated to add it; that update is rejected.
ExpiryNo expiry selection.Optional spec.expiry, but if selected it requires RFC3339 expiresAt. Presence is creation-time-only; expiresAt is immutable. extensions defaults to [], requires extendedTo, reason, requestedBy, and recordedAt per entry, and is append-only. Create a replacement request if an existing stack needs expiry.Unaffected when omitted. Adding or removing it, changing the original deadline, or removing/changing an extension is rejected.

The creation-time-only rules are intentionally stricter than ordinary immutability. retention and expiry cannot be selected later, so a failed attempt cannot be repaired with a second update. Plan a replacement request, including any required data or credential handoff, before making either selection.

New request APIs

The following APIs illustrate additions since the baseline. The complete current inventory is in Request schema. They do not alter an existing 0.x object and are therefore unaffected until an adopter deliberately creates one. The required shape below is the create contract; nested required fields apply only when their optional enclosing list or object is supplied.

New kind1.0 create contractAdopter action and existing-object outcome
GrafanaStackInventorystackRef.name is required. Each optional declared entry requires apiVersion and kind, and must identify an object with externalName, name, email, or login.Create only for reviewed, observe-only inventory. No legacy kind exists, so existing requests are unaffected.
GrafanaFleetPipelinesstackRef.name and profile are required.Select a platform-owned profile; it is a new request and existing requests are unaffected.
GrafanaAlertingBundlestackRef.name and provenance are required. Optional contact points, mute timings, templates, inhibition rules, and rule groups have their own required names and payload fields.Choose enforced or createOnly provenance before creating it. Existing requests are unaffected.
GrafanaAgentObservabilitystackRef.name is required. Optional guard and workload lists have required identities and, for evaluators, evaluatorId, kind, config, outputKeys, and version.Create a separate opt-in request; do not expect a stack request to infer workload rules. Existing requests are unaffected.
GrafanaAssistantGovernancestackRef.name and termsAcceptance.accepted are required. Rules and MCP servers default to empty lists; supplied entries require their names and scopes.Record acceptance, then create this separate request. Existing requests are unaffected.
GrafanaDatasourceAccessstackRef.name, datasource.uid, datasource.name, datasource.type, datasource.authMode, and teams are required. metadata.name must equal datasource.uid; the UID and stack reference are immutable.Create one namespace-local owner for a datasource. The immutability rule rejects later UID or stack-reference changes, but there is no 0.x object to migrate.
GrafanaProvisioningRepositorystackRef.name and repository.uid, title, url, branch, path, and connectionRef.name are required. A dashboard field is rejected.Create it for a Git-provisioned subtree and keep classic Dashboard ownership on the stack request. Existing requests are unaffected.
GrafanaK6ProjectstackRef.name, grafanaUser, and allowedLoadZones are required.Create a dedicated project request after the referenced stack is ready. Existing requests are unaffected.
GrafanaSyntheticMonitoringstackRef.name is required and metadata.name must equal it. Each supplied check requires name, type, target, frequencySeconds, and probeNames; HTTP checks require only http.method, and browser checks require only browser.script. The stack reference is immutable.Create a separate check-set owner. Invalid check-mode combinations and later stack-reference changes are rejected; existing requests are unaffected.
GrafanaStackLadderorganization, region, promotionDirection, rungs, and repository are required. A repository requires url and path; each rung requires name, slug, displayName, usage, branch, and connectionRef.name. Organization, region, rung names/order, and rung slugs are immutable.Define the complete ladder at creation. Later changes to those identities are rejected and require a separately reviewed migration; existing requests are unaffected.

What is newly required, immutable, and optional

The distinctions matter during adoption:

  • Newly required on the existing stack kind: spec.organization. It is also immutable, so validate the selected registry key before applying it.
  • Newly creation-time-only on the existing stack kind: the presence of spec.retention and spec.expiry. Within them, retention.class and expiry.expiresAt are immutable; expiry extension records can only be appended.
  • Newly optional on the existing stack kind: spec.products and its three false-defaulted switches. Leaving them absent does not enable a product.
  • Newly immutable on newly introduced kinds: datasource UID and stack reference, synthetic-monitoring stack reference, and ladder organization, region, rung identities, and rung order. These constrain updates only after the adopter has chosen to create that new kind.

Verification after migration

Use the current XRDs as the contract, then check the request rather than only the apply exit code:

kubectl apply --dry-run=server -f migrated-stack.yaml
kubectl get grafanacloudstackrequest -n example-vending examplestack01 -o yaml

Confirm that the selected organization resolves in the platform registry, that the rendered output location uses the organization segment, and that no rejected compatibility or creation-time-only change remains in the request. Keep all examples outside the live-request directory until that review is complete.

Existing-stack ownership and cross-cluster consumption

This section has two deliberately separate operations. A GrafanaStackConsumer creates a new, profile-owned credential for a stack that another cluster already owns. It does not transfer ownership of the stack or reuse the source stack's credentials. Moving the GrafanaCloudStackRequest itself transfers ownership and must have no overlapping full-stack writers.

Existing-slug result

Design position from pinned source: adopt and reconcile; do not duplicate. A newly reconciled GrafanaCloudStackRequest renders a managed Stack with crossplane.io/external-name set to its slug. The pinned Terraform provider reads that identity through GetInstance, so Crossplane can observe an active existing stack before it considers its Create branch. A found stack therefore is adopted into reconciliation; if its desired fields differ, the normal management policy permits an update. The Terraform resource's direct Create path also rejects an already-taken slug, so a Create branch that is reached fails as a conflict rather than making a second stack.

This is a source-derived design position and is unproven against live behaviour. Do not use a production stack to turn it into a test. Validate the provider version, provider configuration, and rendered external name in a disposable environment before a production handoff.

The conclusion depends on the pinned provider version and its enabled management-policy support:

  • Provider v2.14.0 pins Crossplane Runtime v2.1.0, Upjet v2.2.0, and Terraform Provider Grafana v4.45.1 in its module definition.
  • Its namespaced Stack controller wires management policies into both the connector and reconciler when the feature is enabled (controller). Provider v2.14.0 defaults --enable-management-policies to true and also accepts ENABLE_MANAGEMENT_POLICIES; keep that setting enabled (provider entry point, feature activation).
  • With the feature disabled, Runtime v2.1.0 rejects a non-default policy. With it enabled, Observe alone has no Create action (validation, action resolution).
  • Runtime observes before it reaches Create. In Observe-only mode a missing external resource is reported as an error, while Create is entered only when ShouldCreate is true (observe and missing-resource path, Create gate). A normal managed Stack updates observed drift when Update is allowed (update gate).
  • The provider explicitly defaults Cloud Stack import identity to the slug, uses ExternalNameAsID, and disables the name initializer (Cloud configuration). Upjet puts that external identity into Terraform parameters, reconstructs a missing state with that ID, and refreshes it before reporting ResourceExists (ID parameters, initial state, refresh).
  • The Terraform Stack resource defines the provider-assigned ID and importer (resource schema), reads a stack through its identity (read path), and rejects a taken active slug on Create (Create path).

Consume a stack without transferring it

Use GrafanaStackConsumer only after the platform has approved a profile whose providerConfigName, target slug and region, scopes, consumer name, and output secret path are all fixed. The request supplies the target slug, region, and profile; it never supplies a stack ID, provider configuration, scopes, consumer identity, or output path. Its namespace must equal the profile namespace and its name must equal the profile name. The stack and profile are immutable.

The consumer first renders the namespaced mutating Stack with managementPolicies: ["Observe"] and an external name equal to the slug. It must emit no AccessPolicy, rotating token, or PushSecret until status.atProvider.id is present. That ID is provider-observed input to an AccessPolicy realm of type stack; an organization realm is not an equivalent fallback.

  1. Confirm the target profile authorizes the exact slug and region and that its organization ProviderConfig is healthy. Confirm the provider's management-policy feature remains enabled.
  2. Apply the consumer request and wait for the observer to report a positive status.atProvider.id. A missing stack is a reconciliation error, not permission to create one.
  3. Verify that the rendered AccessPolicy realm uses that observed ID, then wait for the rotating-token child and its PushSecret to become Ready and Synced.
  4. Consume the new profile-owned output path only after its document has been verified. The consumer profile's name and output path must be unique in every cluster targeting that organization and must not be the source stack credential path. Admission enforces uniqueness within the local profile list; the platform owner must enforce the cross-cluster boundary.

This operation leaves the source GrafanaCloudStackRequest, its external Stack, and its credential documents in place. It is appropriate when a second cluster needs a separate credential, not when that cluster will own the stack.

Transfer full stack ownership between clusters

This is a controlled ownership handoff, not a consumer rollout. A source and target full-stack Composition must never concurrently reconcile the same slug, and two PushSecret resources using Replace must never concurrently write the same remote key.

  1. Freeze source and target promotion. Inventory the source request, all composed-resource external names, non-secret provider IDs, dependent requests, and every remote credential document path. Render the target request and compare its proposed ownership and output paths before either cluster reconciles it.
  2. On the source request, confirm spec.lifecycle.externalResources: Retain and that deletion is not armed. Retain is the default, but an earlier approved Delete path must be disarmed before continuing. Record the source stack's Ready and Synced conditions and the evidence that the external stack exists.
  3. If a target cluster needs credentials during the handoff, use the consumer procedure above with a distinct profile-owned consumer name and output path. Move that consumer to its new document and verify it. Do not point it at, or let it replace, the source stack credential document.
  4. Remove the source dependent requests and their writers through reviewed GitOps changes first, after verifying each managed resource uses a non-deleting policy. The Stack request's Retain field does not configure independent dependent requests. Wait for their Kubernetes objects and finalizers to disappear, including whole-set and output-document writers. Then remove the source full-stack request, wait for its composite and composed-resource finalizers to disappear, and prove the external Stack still exists. Do not continue while any source writer remains active.
  5. Only after step 4, apply the target GrafanaCloudStackRequest with the same slug and reviewed organization, region, usage, and lifecycle values. Watch the managed Stack's external name and conditions. Stop on any Create event, changed identity, unexpected update, or external deletion.
  6. Check the entire rendered target graph. The full-stack Composition can create core service accounts, tokens and content automatically after Stack observation; it has no generic one-family-at-a-time adoption switch. Review those identities and effects before step 5. Separately owned dependent APIs can be reintroduced one at a time after their former writers are gone. Provider-assigned credential identities are not inferred from Kubernetes names: new credentials may be minted, and retained source credentials must be retired through the procedure in Retire the source-held credentials once consumers have moved.
  7. Handle the generated-credentials path explicitly. A retained source PushSecret document can remain in the secret store, but it is no longer a current writer. If the target keeps that path, start its writer only after the source writer is gone. If the target uses a new path, migrate every consumer and verify the new document before retiring access to the old one.
  8. Resume promotion only after the target reports Ready and Synced, the final inventory matches, each consumer uses its intended path, and rollback has been rehearsed without deleting the external Stack.

Retire the source-held credentials

Step 8 ends with the target owning the stack. It does not end with the source cluster holding nothing. Under Retain, removing a Kubernetes object is deliberately not a revocation: the external AccessPolicy, its rotating token and any delivered secret-store document all survive the cluster that vended them, and they survive with their original scopes. Retiring them is a separate, ordered step, and it is the last one in the handoff.

Four classes of retained credential are in scope. The first three carry provider-assigned identities that are never derivable from a Kubernetes name; the fourth is the delivered copy of a credential rather than the credential itself:

  • the source AccessPolicy, keyed region:policyID;
  • every AccessPolicyRotatingToken that policy issued, each keyed region:tokenID. These are the live secrets. Do not assume one token per policy: inventory them from the provider side rather than from the source cluster's rendered children, because a token issued out of band is invisible to the cluster and is exactly the one that outlives the migration;
  • any StackServiceAccount the source full-stack Composition created automatically, keyed stackSlug:serviceAccountID, together with its every StackServiceAccountRotatingToken issued against it. The service account is an identity; its tokens are the administrator credentials the stack-local provider configuration authenticates with. Every rotating token authenticates on its own, so each is a live secret, and retiring one covers none of the others. Track each token and its own consumers separately through the replacement proof and the cleanup;
  • the delivered copies: the remote secret-store document the source PushSecret wrote, and the in-cluster connection secret it read from.

Inventory the first three from the managed resource external-name annotations and provider status before anything is removed. The fourth is not a managed resource and has no external name, so record it separately from the PushSecret itself: its namespace and name, each spec.data[].match.remoteRef.remoteKey it writes, the spec.secretStoreRefs entry that key is written through together with the backend and tenant that store resolves to, and the namespace and name of the source Secret in spec.selector.secret.name.

Record the store identity with every key, and compare on the pair. A remoteKey is only unique within its store: the same key string in two different stores is two different documents, so comparing keys alone will either spare a stale source document or delete a live target one. Removing the PushSecret is what makes all of these references unrecoverable, so record them first and use the recorded values in the cleanup step.

Check every source PushSecret for spec.deletionPolicy: None here, during the inventory, before step 4 of the transfer procedure above removes the output-document writers. That is the last moment the check is possible. This repository renders Delete whenever the request has deletion armed, and removing a PushSecret under Delete deletes the remote document with it, destroying the credential delivery before any retirement check has run and destroying it for the target as well if the target adopted that path. Correct the policy, or disarm deletion, before any writer is removed.

A credential you did not record here cannot be revoked from any cluster inventory afterwards, because no cluster names it any more. The Grafana Cloud organization's own listing is then the authoritative record of its identity, and the only place you can go to revoke it. That is separate from its value, which may still sit in remote documents, in a CI secret, or in some other out-of-band copy that no inventory will show you.

Prove the replacement before retiring anything. Every one of these must hold. They are not all observed in the same place, and the difference matters: points 1 to 5 read the target cluster and the provider identities it reports, while point 6 can only be answered by the consumers themselves and by provider-side authentication records. Nothing this repository ships observes any of it, so treat the whole list as an operator prerequisite. A target composite reporting Ready and Synced says the target minted its own credential; it says nothing whatever about whether any consumer has adopted it.

  1. the target composite reports Ready and Synced;
  2. the target AccessPolicy reports a provider-assigned status.atProvider policy ID that is different from the inventoried source policy ID. An identical ID means the target adopted the source policy rather than minting its own. That retires the policy from this procedure and nothing else: its rotating tokens, any service-account tokens, and every delivered copy are still source-held and still go through the rest of these checks. Skip the policy revocation in the retirement steps, and record explicitly that you did and why, so the next operator does not read the gap as an omission;
  3. every target AccessPolicyRotatingToken for that policy reports Ready and Synced, and each of their provider-assigned token IDs differs from every source token ID inventoried for the policy. Both sides are sets: check all target tokens against all source tokens, not one against one. The policy comparison in point 2 does not cover this, because a token is a separate provider identity, and a match on any pair means that source token is still the live credential. Proceed only when all target tokens are Ready and Synced and no pair matches;
  4. the target PushSecret reports the ESO condition Ready=True with reason Synced. That is ESO's own condition, not the Crossplane Ready and Synced pair used elsewhere in this guide. Then read the remote document and the source Secret directly and confirm they hold the target credential; neither is a Crossplane resource and neither carries conditions to check;
  5. for every retained StackServiceAccount, and for every rotating token inventoried against it, the target reports its own provider-assigned service-account ID and token IDs, and each of those target IDs differs from every inventoried source ID for that identity, not from a position-matched one. Do not revoke while any target ID matches any source ID. These are separate identities from the access policy and they need their own comparison; a Ready target composite does not imply they were replaced;
  6. every active consumer has demonstrably reloaded the target credential. Reading the target path is a configuration fact, not a runtime one: a process that loaded the source token at start-up keeps presenting it until it restarts or refreshes, and it will keep succeeding right up to the moment you revoke. A successful call after the write is not enough on its own either, because the call succeeds identically on the old credential. Take either an explicit per-consumer reload confirmation, or provider-side evidence that the successful operation authenticated as the target token ID. A configured path, a healthy target cluster and a green request are together still not this evidence.

Then retire, in this order. The order is not cosmetic: removing the Kubernetes owner first is what stops the source Composition from immediately re-minting whatever you revoke.

  1. Confirm the source PushSecret writer is gone. Step 4 of the transfer procedure above will normally have removed it already; remove it here if it has not, through a reviewed GitOps change. Either way it must have carried spec.deletionPolicy: None when it was removed, per the inventory check above. Under None the remote document survives the removal and still holds a working token, which is what the rest of this procedure assumes. If a writer was removed under Delete, stop: the document is already gone and you are recovering a delivery, not retiring a credential.
  2. Remove every inventoried source rotating token object for that policy, then the AccessPolicy object, from the source cluster, still under non-deleting policies, and wait for their finalizers to clear. Repeat step 1 for each token that had a writer of its own. Nothing has been revoked yet at this point.
  3. Revoke at the provider: every inventoried token for that policy first, one at a time, and the policy itself only once none remain. Revoking the policy invalidates its tokens, so the reverse order leaves a window where the policy is gone and each token's failure mode is harder to attribute. A token you inventoried from the provider but never saw in the source cluster is revoked here like any other; it is the one most likely to be missed.
  4. Delete the stale remote secret-store document and the in-cluster connection secret, using the remoteKey and source Secret references recorded in the inventory.

    Check ownership of each recorded remoteKey first, and delete only the source-only ones. Step 7 of the transfer procedure permits the target to keep the source output path. Where it did, that path is now the target's live document with the target's writer behind it, and deleting it destroys a working credential delivery. Compare each recorded key against the target's own PushSecret remoteKey set: delete a key only when it appears in the source inventory and has no remaining live writer of any kind, matching on the store identity and the key together rather than on the key alone. The target's PushSecret set is the usual second owner but it is not the only possible one: a pair still claimed by any writer stays.

    The in-cluster connection Secret needs its own, separate check: the remoteKey comparison says nothing about it. Before deleting it, look for any remaining local owner or consumer of that namespace and name, including another PushSecret selector and any workload mounting it. Keep the Secret while anything still references it, whatever the remote comparison said.

    This deletion authenticates with the configured SecretStore's own workload identity for the selected backend, never with the Grafana token just revoked, so confirm that identity can delete the recorded key before starting step 3. A revoked token left in a source-only document is an operational trap: it will be found, tried, and its failure misread as a target-cluster fault. 5. Then, for each retained StackServiceAccount individually, and only once point 5 of the replacement proof holds for that specific service account: 1. remove any writer delivering its token, and confirm it is gone. The same retention precondition applies: it must carry spec.deletionPolicy: None, or removing it takes the remote document with it. Where the target adopted that same remote path, the document is the target's and must survive until the source-only ownership comparison in point 4 below has cleared it; 2. remove every inventoried StackServiceAccountRotatingToken for it, then the StackServiceAccount itself, from the source cluster under non-deleting policies, and wait for the finalizers to clear. Nothing is revoked yet; 3. revoke at the provider: every one of its tokens first, one at a time, then the service account once none remain; 4. delete each of those tokens' delivered copies, remote and in-cluster, using their own recorded references and the same source-only ownership comparison as step 4 above. A service-account token path the target adopted is the target's document now.

Do not batch these across service accounts and do not carry the access policy's replacement evidence across to any of them. Each one is a separate provider identity with its own replacement to prove.

Revoking too early takes production down with no rollback. A provider identity is assigned, not chosen, so a revoked policy or token cannot be restored, and its replacement necessarily has a different ID. Any consumer still holding the old value fails closed and stays failed until it is repointed by hand. This is why the replacement evidence above is a precondition and not a checklist to fill in afterwards.

Never revoking is the more common outcome and the worse one. The credential outlives the cluster that vended it, the GitOps repository that described it and the reviewers who approved its scopes. Because the source Kubernetes objects are gone, it appears in no cluster inventory at all: only the Grafana Cloud organization still knows it exists. A handoff that stops at step 8 leaves a full-scope standing credential behind on every migration, permanently.

Operator prerequisites. This repository makes no live Grafana Cloud, cluster or source-environment contact, so none of the following is exercised or proven here, and each is the operator's to carry out and verify:

  • confirming provider-side revocation actually took effect, rather than inferring it from the Kubernetes object being gone;
  • confirming through the Grafana Cloud organization's own access-policy inventory that no retained policy or token from a previous owner remains;
  • establishing that no out-of-band copy of a token was taken before revocation, which no cluster can determine;
  • the rehearsal itself. Rehearse this procedure against a disposable stack before running it against one carrying traffic.

Why GrafanaStackInventory is not the handoff mechanism

GCV-0016's GrafanaStackInventory is explicitly ruled out for this operation. Its required spec.stackRef.name points to a local GrafanaCloudStackRequest, and its provider sets observe objects inside that stack: folders, dashboards, teams, users, library panels, probes, collectors, and organization users. It neither observes the Cloud Stack resource nor supplies its provider-assigned stack ID. Use it for in-stack drift inventory, not cross-cluster identity or claim-side adoption.

General existing-resource adoption

Do not point this platform at existing stacks casually. Adoption is a change of controller ownership, not just a manifest deployment.

A safe adoption rehearsal should:

  1. render and apply the GrafanaStackInventory example for the existing stack, then review its status.declared, status.observedAndManaged, and status.observedButUnmanaged output;
  2. back up the existing configuration through the supported Grafana APIs or its current IaC state;
  3. render the request and inspect every desired managed resource before applying;
  4. begin with create-only or observe-only modes where available;
  5. verify that the Stack external name resolves to the intended existing identity;
  6. confirm no unrelated service account, token, SSO provider, plugin, role, or dashboard would be claimed;
  7. hand over one resource family at a time;
  8. retain a tested rollback that removes Kubernetes ownership without deleting external resources.

The inert examples/catalog/stack-inventory catalog is the migration entry point. Copy and adapt it into the deliberate live-request path only after the referenced stack and its per-stack ProviderConfig are healthy. It composes only the provider's observe-only data sources, so it cannot create, update, or delete Grafana objects. Use its observed lists to identify out-of-band objects before adding declarations or moving a resource family into an owning Composition.

The provider imports external objects through the crossplane.io/external-name annotation. This reference emits an identity only when it can derive the provider's import key without consulting a live environment:

ResourceDeterministic external name
Stackrequest slug
Baseline Folderdeclared Folder UID
Baseline DashboardUID embedded in the declared dashboard JSON
Custom Roledeclared role UID
Whole-set RoleAssignmentdeclared role UID
RoleAssignmentItemdeclared role UID, literal actor type, and the observed bare Team ID: roleUID:team:teamID
FolderPermission or DashboardPermission with a direct UID targetdeclared target UID

Do not infer the remaining import keys from Kubernetes names, display names, list order, or another resource's UID. Inventory them from the existing managed resource annotation and provider status or from the supported Grafana inventory API. The pinned provider uses keys such as stackSlug:serviceAccountID for StackServiceAccount, region:policyID for AccessPolicy, orgID:teamID for Team, region:tokenID for AccessPolicyRotatingToken, and orgID for OrganizationPreferences. A rotating service-account token and any resource without a documented importer must be recorded verbatim and rehearsed against the pinned provider; do not manufacture an ID. Whole-set content permissions that target another managed resource by reference also require the resolved target UID to be inventoried.

For a non-destructive orphan-and-adopt transition:

  1. pause automated promotion and confirm Delete is absent from every external managed resource involved;
  2. export a mapping of logical composed-resource name, kind, external-name annotation, relevant non-secret status IDs, and external object URL or UID;
  3. render the replacement Composition and compare that mapping before applying it;
  4. let this function supply deterministic identities, and add provider-assigned identities through a reviewed adoption field or dedicated migration Composition;
  5. move one resource family at a time, requiring the original identity plus Ready and Synced before continuing;
  6. stop on any Create attempt for an inventoried object, identity change, duplicate external object, or deletion event;
  7. keep provider-assigned adoption inputs durable until the standard Composition can preserve the same identity, then remove migration machinery only after another steady-state check;
  8. resume automated promotion after the complete inventory matches and rollback has been tested.

Extend the request API or use a dedicated migration Composition for provider-assigned inputs. Do not patch generated managed resources by hand: the Composition will overwrite the patch and can turn an adoption into an attempted create.