ADR-058: Per-Cloud Tenant Composition Strategy
Date: 2026-06-05
Status: Proposed — strategy/direction (not implemented). Vocabulary superseded by
ADR-067: the Tenant/XTenant claim is now Environment/XEnvironment. Scope
note: multi-cloud is deferred — the platform is AWS-only today (Azure/GCP removed), so the per-cloud
Compositions here are forward-looking only. Records how the cloud-neutral Tenant API
(ADR-049, schema) is realized on
more than one cloud. Builds on ADR-048 (federated per-cluster
Crossplane) and ADR-047 (Pod Identity = the AWS instance of a
general pattern). AWS is the only realized cloud today; this charts the GCP/Azure path so the implementation
seam is decided before contact with a second cloud.
Context
Section titled “Context”The Tenant claim is cloud-neutral by construction (ADR-049): a developer never names a cloud, region,
account, or cluster, and the one cloud-specific field (apps.<app>.permissions) is explicitly cloud-keyed
(permissions.aws.…). But the Composition that realizes a claim is, today, AWS-only — it renders
provider-aws managed resources (IAM Role, EKS Pod Identity association, ECR) inline (delivery-plan A3). So
the coupling to AWS lives entirely in the realization layer, which is where it belongs — but the question of
how a second cloud plugs in has not been decided, and the current Composition is monolithic-AWS (the
cloud-neutral and cloud-specific parts share one go-template). Deciding the seam now avoids a painful refactor
later.
Decision
Section titled “Decision”One shared XRD/claim; one Composition per cloud; placement selects which. Concretely:
-
The XRD (contract) is shared and cloud-neutral. All clouds expose the same
XTenanttype. No cloud forks the API. -
Each cloud has its own Composition, bound to that one XRD. Crossplane supports many Compositions per composite type; in the federated model (ADR-048) selection is implicit — each cluster installs only its own cloud’s Composition, so a claim is realized by whatever cluster placement routed it to (the Zone’s
(cloud, region), ADR-049). NocompositionSelectorgymnastics in the claim. -
Factor the Composition into a neutral core + per-cloud overlays. The split already exists in the realization (A3): the Kubernetes footprint (namespace, quota, LimitRange, NetworkPolicies, CiliumNetworkPolicies, developer RoleBinding — all
provider-kubernetes) is cloud-neutral and must be shared verbatim across clouds; only workload identity and registry are cloud-specific. The neutral core is extracted (a shared template/partial) so GCP/Azure Compositions reuse it rather than duplicate it.concern AWS (realized) GCP Azure K8s footprint provider-kubernetes (shared) shared shared workload identity Pod Identity → IAM Role Workload Identity → GCP SA Workload Identity → Managed Identity per-app grants permissions.awspermissions.gcppermissions.azureregistry ECR Artifact Registry ACR -
Workload identity is one pattern with per-cloud instances. “Named K8s ServiceAccount → cloud identity” is the neutral abstraction; Pod Identity (ADR-047) is the AWS instance, GCP/Azure Workload Identity the others. The claim’s
serviceAccountis the neutral binding point;permissions.<cloud>(a map, already shaped for it) carries the cloud-keyed grants. -
Human identity is already cloud-neutral — Keycloak (ADR-053) brokers app SSO independent of cloud; no per-cloud work there.
Alternatives considered
Section titled “Alternatives considered”- A single mega-Composition that branches on
cloudinside one template. Possible, but it grows unreadable and couples every cloud’s lifecycle into one artifact; the federated model already gives clean per-cluster separation. Rejected in favor of per-cloud Compositions sharing a neutral core. - A cloud-specific claim per cloud (forked XRDs). Destroys the portability that is the entire point of the neutral API (ADR-049). Rejected.
- A central multi-cloud control plane reaching into every account/project. Contradicts the federated per-cluster decision (ADR-048) and concentrates blast radius. Rejected.
Consequences
Section titled “Consequences”- The multi-cloud promise becomes “stand up a Zone + write its Composition + route to it,” not “redesign the API” — the neutral claim survives unchanged.
- Required refactor before a second cloud: extract the K8s-neutral core from the current AWS Composition so it isn’t duplicated. Until then the Composition is monolithic-AWS (acceptable while AWS-first).
- Schema follow-up: add
permissions.gcp/permissions.azurewhen those clouds land (the field is a map; no breaking change). - Deferred (explicitly): non-AWS Zone vending (the GKE/GCP-project or AKS/subscription factory) — the one place a cloud-specific provisioning mechanism is justified (ADR-049), behind a cloud-neutral interface; and the GCP/Azure Crossplane provider set + their identity/registry overlays.
- Cross-cloud image references differ (registry host per cloud) — surfaced to workloads via the Zone’s
EnvironmentConfig /
status.placement, not the claim.