Repository navigation
Description
Summary
Generalize the Tenant archetype — the platform-side control-plane resources we hand-copy for every tenant — into a single reusable abstraction, so onboarding a tenant becomes one small declaration instead of a ~7-file copy. Explicitly evaluate projectcapsule/Capsule (buy) before building our own (KRO/Helm).
This is the structural follow-up to the incremental DRY pass (#1927–#1931). Those squeezed the copy-paste out of the infra layer; the per-tenant plumbing is the remaining large archetype.
Current state (what a Tenant abstraction would own)
Each tenant duplicates a near-identical control-plane skeleton. For ascoachingogvaner:
k8s/bases/apps/ascoachingogvaner/namespace.yaml…/serviceaccount.yaml(the Flux-impersonated, namespace-scopededitSA)…/rolebinding.yaml…/sync.yaml(cosign-verifiedOCIRepository+ FluxKustomizationpointing at the tenant's own artifact)…/ghcr-auth-externalsecret.yaml(ESO → dockerconfigjson)…/networkpolicy.yamlk8s/providers/hetzner/apps/ascoachingogvaner/patches/kustomization-patch.yaml
wedding-app adds secretstore.yaml + sops-age-secret.enc.yaml. The two skeletons differ only by name/namespace/OCI URL and the SOPS block — and they already drift (e.g. inconsistent app.kubernetes.io/managed-by: ksail labels).
Goal & constraint
One typed/abstracted Tenant → a ~5-line declaration per tenant. Must not add more Kustomize overlay/component stacking — "overlays on overlays on overlays" is hard to read without a visualization, which is the whole reason we're reaching for an archetype rather than another component.
Options to evaluate
- Capsule (CNCF multi-tenancy operator) — buy, recommended to assess first. Purpose-built: a
TenantCRD groups namespaces and auto-inherits ResourceQuota, LimitRange, NetworkPolicy, and RBAC; supports namespace auto-provisioning andadditionalMetadatafor traceability. Much more mature than KRO. Open questions to resolve:- Does its owner-based namespace-grouping model fit our mostly single-namespace + Flux-impersonation tenants, or is it overkill?
- Coexistence with Flux: Capsule doesn't manage Flux sources, so the
OCIRepository+ cosign verification and the per-tenant FluxKustomization(withserviceAccountNameimpersonation) stay as-is — Capsule would own namespace/RBAC/netpol/quota. Capsule + Flux is a documented pairing; confirm the division of ownership. - Overlap with our Kyverno
restrict-tenant-secret-storespolicy + the tenant-scoped Vault/OpenBao role and the ghcr-authExternalSecret— keep, or fold into Capsule's policy model?
- KRO
TenantRGD — build. Typed,kubectl-native, but KRO is pre-1.0 (v1alpha1) and would add a new alpha controller + dynamic CRDs in theinfrastructure-controllerslayer — the exact chokepoint our incident history keeps wedging. See the KRO assessment (below). - Helm library chart — build, simplest. Templates the ~7 resources behind one HelmRelease; stays 100% inside the existing Flux→HelmRelease reconcile/prune model; no new controller. Good fallback if Capsule's model doesn't fit.
Platform constraints any solution must respect
- Flux order
bootstrap → infrastructure-controllers → infrastructure → apps; any new controller (Capsule/KRO) lands ininfrastructure-controllers, its CRs one layer later (CRD-before-CR deadlock risk). ksail workload validatebuilds every kustomization standalone — CRs referencing a not-yet-applied CRD need--skip-kinds(as we already do forCoroot).kubectl kustomize k8s/clusters/*does not catch this (it renders only Flux CRs).enforce-flux-best-practices(Enforce) requiresinterval/timeout/retries on any templatedHelmRelease/Kustomization.- Secrets stay in ESO + SOPS — never inline secret material into an instance/tenant spec.
- Preserve the isolation posture: cosign-verified OCI, Flux-impersonated
editSA, Kyverno-restricted namespaced Vault SecretStore.
Acceptance criteria
- Decision recorded (ADR) on Capsule vs KRO vs Helm library chart, with the build-vs-buy rationale.
- One tenant migrated as a local-first pilot; behavior-preserving where applicable (render-diff +
ksail workload validateunchanged vs baseline). - Onboarding a new tenant becomes a small declaration (no ~7-file copy).
- No added Kustomize overlay/component stacking.
- Isolation posture preserved (cosign, Flux impersonation, Vault scoping, netpol).
Related
- Composes with the WebApp archetype (a Tenant hosts WebApp workloads) — see the sibling issue.
- Incremental DRY already shipped: refactor(infrastructure): hetzner tracks controller aggregate, not a hand list #1927, refactor(infrastructure): centralize HelmRelease Flux remediation defaults #1928, refactor(infrastructure): use path-only kustomize patches where self-identifying #1929, style(kustomize): add yaml document-start markers + normalize list indent #1931.
🤖 Filed from an architecture discussion with Claude Code. Sources: projectcapsule/capsule, Project Capsule docs, kro.run.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fields✅ Done
Activity
devantler commentedon Jun 9, 2026
Sibling archetype (composes with this one): #1933
[-]Generalize the Tenant archetype — evaluate Capsule vs KRO vs Helm library chart[/-][+]Generalize the Tenant abstraction — evaluate Capsule vs KRO vs Helm library chart[/+]devantler commentedon Jun 17, 2026
ADR (Proposed) — Tenant abstraction: Capsule vs KRO vs Helm chart
Delivering acceptance-criterion 1 ("Decision recorded on Capsule vs KRO vs Helm library chart, with the build-vs-buy rationale") as a Proposed decision record for your ratification — the build-vs-buy call is yours; this is the reasoned recommendation + pilot plan to act on. Status flips to Accepted when you promote it (or redirect).
Decision drivers (weighted by this platform's reality)
infrastructure-controllers. That layer is this platform's recurring wedge point (CRD-before-CR deadlocks; the merge-queue prod-deploy gates on apps health; nftables/Longhorn boot incidents). Adding an operator there is the highest-cost move available and the issue itself flags it.OCIRepository, Flux-impersonated namespacededitSA, Kyvernorestrict-tenant-secret-stores+ namespaced VaultSecretStore, ghcr-authExternalSecret, netpol.ksail workload validateparity, no added overlay/component stacking.Evaluation
KRO (build, typed RGD) — REJECTED. Pre-1.0
v1alpha1; lands an alpha controller + dynamic CRDs squarely in the wedge-proneinfrastructure-controllerslayer (driver 1 ✗). The typing/kubectl-native upside doesn't pay for that risk at this scale. Revisit only post-1.0 with a concrete typed-API need.Capsule (buy, CNCF multi-tenancy operator) — DEFER (evaluated, not now). Mature and purpose-built, but a poor fit here:
OCIRepository+cosign, impersonatingKustomization), the ghcr-authExternalSecret, theSecretStore, and the SOPS block — none of which Capsule manages (it explicitly doesn't own Flux sources). Sosync.yaml/ghcr-auth/secretstorestay copy-pasted; the 7-file copy becomes a ~4-file copy.TenantCRD ininfrastructure-controllers(CRD-before-CR deadlock; needsksail workload validate --skip-kinds, like Coroot). And it overlaps the existing Kyverno/Vault/Flux-impersonation posture — folding those into Capsule re-architects a working, audited isolation model; running both = two overlapping policy engines.Helm chart (build, simplest) — RECOMMENDED. A per-tenant chart (a library chart with thin wrappers, or a standard app chart instantiated via
HelmReleasevalues) templating all ~7 resources; onboarding becomes a ~5-line values block (name, namespace, OCI URL, optionalsops:).helm-controlleris already in the platform — HelmReleases are everywhere. Nothing new entersinfrastructure-controllers.HelmReleaseplacement + chart-source (local OCI/GitRepository chart path) needs a concrete choice in the pilot; (c) Helm release-ownership prune semantics differ slightly from a raw Kustomization — verify prune-on-removal in the pilot; (d)enforce-flux-best-practicesrequires templatinginterval/timeout/retries into the generated HelmRelease/Kustomization (bounded, known).Recommendation
Option 3 (Helm chart) for iteration 1 — the only option that adds no controller to the fragile layer, de-duplicates the whole skeleton, and preserves the isolation posture unchanged, while matching the current single-namespace scale. Capsule deferred (revisit at fleet scale), KRO rejected.
Consequences / pilot (remaining acceptance criteria)
tenantchart: namespace, SA, rolebinding,OCIRepository+cosign, impersonatingKustomization(with best-practice interval/timeout/retries), ghcr-authExternalSecret, networkpolicy; optionalsecretstore+ SOPS via avalues.sopsconditional.ksail workload validateparity.wedding-app's SOPS block behind the chart conditional for the second migration.Composes cleanly with the WebApp archetype (#1933): a Tenant hosts WebApp workloads, so WebApp becomes another values section / sub-chart under the same Flux→HelmRelease model — no second mechanism.
If you'd rather record this as a committed design doc under
docs/(matching the topic-doc style — there's no ADR dir today,docs/dris Disaster Recovery), say the word and I'll open a draft PR addingdocs/tenant-abstraction.md.devantler commentedon Jun 18, 2026
ADR (Revised) — Tenant/WebApp archetype: Helm ruled out, going Custom-Resource-based
Maintainer decision (direct): "Custom Resource approach — more cloud-native, so Helm charts are ruled out." This supersedes the Helm-chart recommendation in my prior comment for both archetypes (this issue + the WebApp sibling #1933 — they share one engine). Re-evaluating the CR-composition engine, grounded in what the platform actually runs today.
What's actually deployed (verified against
main, not the stale submodule pin)k8s/providers/hetzner/infrastructure/controllers/crossplane/), not in the sharedinfrastructure-controllerslayer (thedocker/local overlay has no Crossplane). Its only provider isprovider-upjet-github— i.e. it manages external APIs (the GitHub org), which is Crossplane's actual strength. There is noprovider-kubernetes.v1alpha1— actively moving toward 1.0.ksail-operator(your own,ksail.io/v1alpha1) is in the shared controllers layer, HA — but it reconcilesClusterlifecycle + the web UI/REST API, not workloads.CR-engine options
A. Crossplane Composition (XRD + Composition +
provider-kubernetes) — REJECTED (wrong tool here).Reusing Crossplane sounds free but isn't, for in-cluster workload composition:
ksaillocal dev/validation and the "local-first pilot" unless we also move Crossplane into the shared layer.provider-kubernetesadded (not present), and every child (Deployment/Service/HTTPRoute/HTTPScaledObject/ExternalSecret) becomes a wrapped CrossplaneObject— off-label: Crossplane is built to project external APIs into the cluster, not to template in-cluster manifests. That's literally why KRO exists.B. KRO
ResourceGraphDefinition— RECOMMENDED (iteration 1).KRO is purpose-built for grouping in-cluster Kubernetes resources behind a typed CRD:
Tenant/WebAppCRD whose instance renders the child resources directly (noObjectwrappers, no provider).includeWhenconditionals map exactly onto the optional children — KEDA scale-to-zero set, ExternalSecret/SOPS, auth — which is the readability win the issue is asking for.infrastructure-controllerslayer) → local-first pilot +ksail workload validateparity both work; not prod-gated like Crossplane.v1alpha1controller in the wedge-proneinfrastructure-controllerslayer. You've accepted the CR+controller path; this is the cost of admission. Mitigate by pinning a known-good version, treating it like the other operators (HA, PDB, netpol, replica-floor exception), and keeping the RGD render-diffable.C. Extend
ksail-operatorwith aTenant/WebAppCRD — the maximal-ownership alternative.Add the archetype as a first-party CRD reconciled by your own operator (already shared-layer + HA, already serving
ksail.ioCRDs). Most "owned"/dogfooding + fully cloud-native, no third-party pre-1.0 dependency. Cost: real Go controller work in the ksail product + couples platform archetypes to the ksail-operator release cadence. Best if you want to own the controller rather than adopt KRO — but a larger build than B.Recommendation
Option B (KRO) for iteration 1: the lowest-effort CR path that's purpose-built for in-cluster composition, works on all clusters, and whose
includeWhencleanly expresses the optional children — while leaving Crossplane to its external-API role. Revisit Option C (own it in ksail-operator) if KRO's pre-1.0 status or limits bite, or if dogfooding into ksail is the strategic goal. Crossplane rejected for this purpose.Pilot (once you confirm the engine)
k8s/bases/infrastructure/controllers/kro/(HelmRelease + namespace + netpol + PDB + replica-floor exception, matching the operator-tier conventions); add its CRDs toksail.yamlskipKindsand any Kyverno exception needed.TenantRGD (namespace, SA, rolebinding, cosign-verifiedOCIRepository, impersonatingKustomizationwith best-practice interval/timeout/retries, ghcr-authExternalSecret, networkpolicy;includeWhenSOPS) and theWebAppRGD (Deployment/Service, HTTPRoute → centralplatform/kube-systemGateway,includeWhenKEDA 5-point set + the cross-ns ReferenceGrant note,includeWhenExternalSecret).ascoachingogvaner(no SOPS, no scale-to-zero) local-first; render-diff the RGD output vs the current manifests; confirmksail workload validateparity. Thenwedding-appexercises the SOPS + scale-to-zero conditionals.WebAppCR — no Kustomize overlay/component stacking.Status flips to Accepted when you confirm B vs C; I'll then open the draft PR (KRO controller + the RGDs + the local-first pilot).
devantler commentedon Jun 18, 2026
Decision (maintainer, 2026-06-19): KRO.
The Tenant archetype is settled on KRO (
kro.runResourceGraphDefinition) — the purpose-built tool for composing internal Kubernetes resources, matching the "Custom Resource, more cloud-native" steer (Helm ruled out; Crossplane kept for external-API/prod-only).Incremental implementation (each behind a draft PR):
v0.xand kept additive (KRO is pre-1.0 /v1alpha1).includeWhen.The WebApp archetype (#1933) builds on the same KRO foundation.
fix(kro): grant the Tenant controller watch on the tenants instance kind
fix(kro): grant the Tenant controller watch on the tenants instance k…
devantler commentedon Jul 2, 2026
Step 3 (local-first pilot) started: #2388 (child issue) → draft PR #2389 ships the pilot scaffolding — an opt-in
TenantCR for ascoachingogvaner on the Docker provider (renders unchanged by default),Tenantin both configs' validateskipKinds(the runtime-generated CRD has no catalog schema), and the TENANTS.md pointer. The live local pilot is now a one-line opt-in; the prod ownership transition (Flux-prune-vs-namespace hazard) is deliberately gated on the pilot and will be designed as the next child.devantler commentedon Jul 5, 2026
ADR (Proposed) — Step 4: prod ownership transition, skeleton → Tenant CR (zero-prune migration)
Step 3's scaffolding merged (#2388/#2389); before the live pilot and any prod swap, this records how the live, Flux-owned skeleton objects change hands to KRO without ever being deleted — the tenant namespace holds the CNPG database, and the tenant's own Flux Kustomization carries a kustomize-controller finalizer that garbage-collects the entire app if that Kustomization object is ever deleted with
prune: true. Deletion anywhere in the handover is data loss, so the design goal is a migration in which no delete is ever issued.Mechanism facts (verified in kro v0.9.2 source — the version our chart 0.9.2 ships)
FieldManager: kro.run/applysetand unconditionalForce: true(pkg/controller/instance/applyset/applyset.go:313–316). The only refusal is a liveapplyset.kubernetes.io/part-oflabel pointing at a different ApplySet (applyset.go:345–367); Flux-created objects carry no such label, so kro takes field ownership fromkustomize-controlleron first reconcile — no "already exists" error, no downtime. Identity is the adoption key, and all 10 skeleton objects already match the RGD templates by kind/name/namespace and field-for-field spec (verified side-by-side today).kro.run/owned,applyset.kubernetes.io/part-of, instance-id labels); cross-namespace children (instance influx-system, children in the tenant ns/kube-system) and the cluster-scoped Namespace are covered viaadditional-namespaces/contains-group-kindson the parent. Our topology is fully supported.appsKustomization's source while still in its inventory, kustomize-controller deletes them unless they carrykustomize.toolkit.fluxcd.io/prune: disabled. kro would recreate them — but for the Namespace and the tenant Kustomization, "delete then recreate" is the catastrophic path (cascade / finalizer GC). Prune-protection must be live before the swap.kro.run/finalizerwalks children in reverse-topological order and deletes them all;DeletionPolicyis an unimplemented TODO, andkro.run/reconcile: suspendeddoes NOT gate deletion (it runs earlier in the reconcile). Orphan/adopt lifecycle policies are open upstream work (KREP-014, KREP-014 Resource lifecycles kubernetes-sigs/kro#1091 — worth watching). So after the swap, deleting the Tenant CR deletes the namespace and the tenant's Flux Kustomization → full tenant GC.Decision (proposed): three phases, each a separate release
kustomize.toolkit.fluxcd.io/prune: disabled. No runtime effect today; it makes the later inventory removal a no-op instead of a delete, and doubles as standing protection against accidental skeleton-file deletion. Ships immediately as a draft PR (child issue below). Must be merged + reconciled on prod before Phase B starts.k8s/bases/apps/ascoachingogvaner/and add the (prune-disabled-annotated)TenantCR to the prod apps overlay. Flux drops the 10 objects from inventory without deleting them; kro adopts them via forced SSA within one reconcile interval. Verification:kubectl get … --show-managed-fieldsshowskro.run/applysetownership + ApplySet labels present, tenant app untouched (no pod restarts, CNPG untouched).kustomize.toolkit.fluxcd.io/*labels/annotations linger under the old field manager (kro keeps foreign labels). Remove them out-of-band or leave them; nothing depends on them.Risk register (post-swap)
TenantCR pruned by Flux (file removed / overlay refactor) → kro finalizer cascades the whole tenantprune: disabledannotation ON the Tenant CR itself (part of Phase B); overlay refactors treat Tenant CRs as protected fileskubectl delete tenantcascadesTenantresources without a break-glass annotationWedding-app follows the same phases after ascoachingogvaner proves out (
sops: truevariant).Objections/redirects welcome here; Phase A ships as a draft PR now, Phase B waits for the pilot + your promotion.
docs(tenant): record ADR on generalizing the Tenant abstraction
docs(tenant): pivot ADR to record KRO as the chosen Tenant abstraction
devantler commentedon Jul 7, 2026
Decision recorded — the Tenant abstraction is KRO (maintainer-confirmed: "We already started implementing KRO. We are not going back on that… enhance the KRO CRs to cover all tenant and platform needs"). The evaluation is closed; the Tenant-abstraction ADR records KRO as chosen (Helm library chart = considered, not chosen).
Remaining #1932 work is enhancing the KRO
Tenant/WebAppCRs, now decomposed into oldest-first children:ExternalSecrets beyondghcr-authResourceQuota/LimitRangeguardrailsWebAppshapeEach is behaviour-preserving and render-diffed vs the current instances. Ownership-transition rehearsal stays tracked in #2487.
devantler commentedon Jul 8, 2026
Triage update — re-verified live state of each AC:
docs/tenant-abstraction.md: KRO adopted over Capsule (wrong tenancy model) and a Helm library chart (a technology detour away from the already-implemented choice).k8s/providers/docker/apps/tenant-ascoachingogvaner.yaml+web-app-wedding-app.yaml: the typedTenant/WebAppCRs render-diff clean viaksail workload validateagainst the RGD-expanded skeleton. Deliberately opt-in (not referenced by the docker appskustomization.yaml), so both cluster renders stay unchanged until a pilot session enables them — matches the AC's "behavior-preserving where applicable" wording.OCIRepository, Flux-impersonatedtenant-editSA, Kyverno-scoped VaultSecretStore, and default-denyNetworkPolicythe hand-copied skeleton used.All 5 ACs are satisfied by what's already merged. Not yet done, and out of this issue's original scope: migrating the live prod/Hetzner tenants (
ascoachingogvaner,wedding-app, still on the old hand-copied skeleton underk8s/bases/apps/+k8s/providers/hetzner/apps/) off the old skeleton onto these RGD CRs — the ADR frames that as a KRO-CRs-enhancement follow-up sequence, not this issue's AC. The three schema-enhancement children are filed and tracked: #2521 (typed secrets — parked on live-cluster verification, kro has no offline CEL validator), #2522 (ResourceQuota/LimitRange — draft #2533, this run), #2523 (stateful WebApp/CNPG).Recommend closing this issue as delivered, with #2521/#2522/#2523 (and a docs-sync follow-up for
TENANTS.md, noted in the ADR) carrying the remaining work — but leaving that call to the maintainer rather than closing it myself, since #1932 is also the issue number the ADR and pilot commits reference throughout.devantler commentedon Jul 11, 2026
Closing as delivered — every acceptance criterion is checked with live evidence (see the 2026-07-08 per-AC triage comment): ADR at
docs/tenant-abstraction.md(KRO adopted), Tenant RGD live with a migrated pilot, small-declaration onboarding, no overlay stacking, isolation posture preserved. Remaining follow-on work is tracked separately (#2523 stateful WebApp shape).fix(apps): stop blanket-annotating Namespaces out of Flux pruning