Docs · Hangar
GitOps strategy
Status: structural design resolved (2026-08-12). §10 built and live-verified
2026-08-16 (see that section’s own status note for the mechanism and what was
proven). This is the first design doc for Dream
IDP ([[project-dream-idp]] in platform-cicd’s memory), which absorbs platform-cicd as
one component rather than replacing it. Where this doc extends a pattern already
live-verified in platform-cicd, it says so and links to the source doc — the intent is
continuity of design language, not a rewrite. Where it makes a new call platform-cicd
never had to make (two ArgoCD instances, many prod clusters, fleet-wide cluster config),
it says that too, and flags it explicitly under “Open questions” at the end rather than
quietly picking one.
Review rounds 1 and 2 (2026-08-12): every open question from the first draft is resolved below, inline in the relevant sections, not just listed at the bottom — search for “resolved” or “confirmed” if you’re skimming for what changed.
Terminology
Carried over unchanged from platform-cicd/docs/multi-cluster.md:
- env — a logical environment name (
dev,staging,prod), tenant-facing. - cluster — a physical Kubernetes cluster, platform-infra-facing. One dev cluster, potentially many prod clusters. An env and a cluster are joined only through a registry, never hardcoded together — this doc keeps that separation and generalizes it from “which cluster does this release land on” to “which clusters does this app exist on at all.”
New for this doc:
- cluster admin — owns cluster-level config: what’s installed, what policy applies, which apps are permitted on a cluster at all. Never touches an app’s own source or deployment config.
- app owner — owns an app’s source repo and its gitops repo. Never touches cluster config, never gets credentials to any cluster’s API.
- fleet — the full set of clusters (one dev + N prod) under one cluster-admin team’s management.
Guiding constraint, carried forward unchanged
platform-cicd’s multi-cluster design (docs/multi-cluster.md) established, and
live-verified twice, a rule this doc treats as foundational rather than re-litigating:
no cluster ever holds credentials for another cluster’s API, and no component on one
cluster ever calls another cluster’s API directly. The only thing that ever crosses
a cluster boundary is a reviewed, merged git commit; outcomes flow back as CDEvents, not
API calls. Everything below is designed to hold that property at fleet scale, not just
for the one dev→staging hop it was originally proven on.
1. Repo topology
Six repo shapes, each with one owner:
| Repo | Owner | Contains |
|---|---|---|
<app-name> |
app owner | source code, cicd.yaml, a platform/ self-service folder — developer-authored Crossplane Claims, plus lower-env definitions (§10) |
gitops-<app-name> |
app owner (writes only via CI-opened, reviewed PRs) | rendered/patched deploy config for that app’s upper environments only — see §10 |
gitops-cluster-<cluster-name> |
cluster admin | one cluster’s static declarative config, organized into logical groups (§3) |
gitops-cluster-<cluster-name>-tenants |
cluster admin (lighter review — see below) | which apps are onboarded to this cluster — the churny per-app-onboarding half, split out of the repo above |
idp-cluster-baseline |
cluster admin | shared Helm chart(s) every cluster’s logical groups pin a version of — the “same tooling everywhere, thin per-cluster diff” layer. See §8 for why this is a Helm chart, not a Kustomize remote base. |
idp-service-catalog |
cluster admin (initially) | Crossplane XRDs/Compositions + the shared app-facing Helm chart(s) that gitops-<app-name> repos build on |
One app repo, one gitops repo — unchanged from platform-cicd. An app that targets
three clusters still has exactly one gitops-<app-name> repo; it just has three
directories inside it (see §5).
Cluster config repo shape: per-cluster, confirmed
You’ve never used a monorepo for a cluster fleet, and that instinct holds up under a real
look at the tradeoffs — per-cluster repos (gitops-cluster-<name>), confirmed.
The argument that actually settles it, not just an ergonomics preference: §6 already
relies on AppProject.sourceRepos as the real, live-tested enforcement boundary for
apps — a throwaway Application pointed at a repo outside an AppProject’s
sourceRepos gets hard-rejected by ArgoCD at the API level, confirmed live in
platform-cicd. That rejection is repo-level only. ArgoCD has no equivalent
primitive for “this AppProject may sync clusters/prod-eu/ of this repo but not
clusters/prod-us/” — AppProject scopes by repo URL, never by path. A monorepo would
leave the actual cluster boundary resting entirely on each Application’s own
spec.source.path being correct — a config-correctness convention, not something
ArgoCD would ever reject if it were wrong. A misconfigured or copy-pasted Application
pointing argocd-platform on prod-eu at clusters/prod-us/ inside a shared repo
would sync without complaint. Per-cluster repos make the same enforced boundary this
doc already relies on for apps (§6) apply to clusters too, for free — this isn’t just
nicer, it’s what makes that boundary mean anything at the cluster level.
Everything else is secondary to that, but worth having on record:
Pros of per-cluster repos:
- Blast radius matches repo permissions, not directory conventions or CODEOWNERS path rules that have to stay correct as the fleet grows.
- A bad merge structurally cannot span two clusters — different repo, different PR, different history.
- Independent audit trail per cluster (
git logon one repo is the full change history for that cluster, no filtering out every other cluster’s noise). - Decommissioning a cluster is archiving one repo, not surgically removing a directory from a shared history.
- CI/PR checks scope naturally per repo — no path-filter logic in a shared pipeline deciding which cluster’s checks should run for a given PR (a common, quiet source of bugs in monorepo CI: a PR touching two clusters’ directories only triggering one cluster’s checks depending on how the filter’s written).
Cons, real, not dismissed:
- N repos to provision instead of one — mitigate with a repo-scaffolding script once
the logical-group directory shape (§3) is finalized; not solved by this doc.
Closed 2026-08-19: see
docs/cluster-provisioning.md—gitops-cluster-template(a real GitHub template repo) +hack/customize-cluster.sh, takingtype: dev|upperand a per-component subset as a declarativecluster.yaml, structurally verified against both real cluster repos. - A genuinely fleet-wide change (a security patch every cluster needs) is N PRs, not
one atomic commit.
idp-cluster-baselineabsorbs most of the actual content-diff cost (the manifest changes once, each cluster repo just bumps a pin), but opening/merging N PRs is still real toil at large fleet size — revisit with a bot that opens all N pin-bump PRs together if the fleet grows large enough for this to hurt. - No single-repo
diffbetween two clusters’ config — needs small tooling (git diffacross two clones, or a script) if “what’s different between prod-us and prod-eu” becomes a frequent question. Consistent logical-group directory naming (§3) across every cluster repo keeps this tractable even without dedicated tooling.
Splitting static cluster config from app onboarding
Per your steer: gitops-cluster-<name> should stay close to read-only in practice —
mostly-static config that changes rarely and carries real review weight. App onboarding
(which apps exist on this cluster) is a different traffic pattern — frequent,
individually low-stakes — and its PR noise shouldn’t dilute review attention on the
rare, high-stakes cluster-config changes. Splitting it into
gitops-cluster-<name>-tenants does that.
Mechanism: the tenant-onboarding ApplicationSet resource itself still lives in
gitops-cluster-<name>/02-argocd-apps/ — it’s infrastructure, it rarely changes, it
belongs with the rest of static cluster config. Only its generator’s git.repoURL field
points at gitops-cluster-<name>-tenants instead of a local path — no separate
Application resource needed, changing that one field is the whole mechanism. Each
entry in the tenants repo is the same shape as platform-cicd’s existing
tenants/<app>/identity.yaml (operator-owned, PR-reviewed, minimal — app name, repo
URLs, namespace; no live config).
Confirmed (review round 2, 2026-08-12): per-cluster. One
gitops-cluster-<name>-tenants per gitops-cluster-<name>, not a fleet-wide tenants
repo — keeps “which repos can this cluster admin touch” answerable by team membership
alone, same property the cluster-config repo split relies on.
2. Two ArgoCD instances per cluster
Every cluster — dev included — runs two ArgoCD instances, matching your requirement directly:
argocd-platform— cluster-admin scoped. Watches onlygitops-cluster-<name>. RBAC restricted to the cluster-admin group. Manages: bootstrap namespaces/RBAC, CRDs and operators (Crossplane + Providers, cert-manager, External Secrets Operator, ingress, Kyverno), the service-catalog XRDs/Compositions themselves, the observability stack, and — see §4 — its own installation, plus the installation and RBAC ofargocd-apps.argocd-apps— app-owner facing. Watches per-app Applications generated by an ApplicationSet (§5), each scoped to a real per-appAppProject(§6). Cluster admins provision the instance and its AppProjects (fromargocd-platform); app owners never get access toargocd-apps’s own admin surface, only to their own app’sApplicationinside their own scoped project.
This is the structural enforcement of requirement 6 (separation of duties): an app
owner’s blast radius is bounded by an AppProject, entirely inside argocd-apps. A
cluster admin’s actions land through argocd-platform, entirely inside
gitops-cluster-<name>. Neither instance’s RBAC nor its watched repo overlaps with the
other’s.
Refinement, live-verified 2026-08-24 (supersedes an earlier 2026-08-16 version of this
note): argocd-apps additionally watches every tenant’s own <type>-<app-name>-cicd
namespace (ArgoCD’s ApplicationSet-in-any-namespace feature,
application.namespaces/applicationsetcontroller.namespaces scoped to a *-cicd glob)
so platform-cicd’s own preview-environment resources (the pr-envs ApplicationSet, a
GitHub-token Secret, matching RBAC — see platform-cicd’s
docs/admin/ephemeral-environments.md) can live colocated with the rest of that
namespace’s chart-rendered resources instead of needing a cross-namespace grant into
argocd-apps. This is argocd-apps, not argocd-platform, deliberately: PR-based
ephemeral environments are app-owner/PR-driven work, matching this section’s own
argocd-apps description above, not cluster-admin work. An earlier version of this same
mechanism briefly lived on argocd-platform instead (2026-08-16 through 2026-08-23,
chasing an unrelated SCM-providers bug found live testing PR-envs) — moved once it was
clear nothing else on argocd-platform needed the wider watch: release-stage
Applications (release-application.yaml) live directly in argocd-platform’s own
namespace and were never affected either way. The per-tenant AppProject
(platform-cicd-app’s templates/argocd/appproject.yaml) moved with it — it now renders
two AppProjects of the same name, one per instance, each only visible to that instance
since an ArgoCD instance only ever looks for an AppProject inside its own control-plane
namespace.
3. Logical groupings inside a cluster’s config (requirement 7)
gitops-cluster-<name> (thin — mostly version pins into idp-cluster-baseline plus
cluster-specific values) renders as one ArgoCD Application per top-level directory,
all owned by a single app-of-apps root inside argocd-platform:
gitops-cluster-<name>/
00-bootstrap/ namespaces, base RBAC, NetworkPolicies, ResourceQuotas
01-argocd-platform/ argocd-platform's own install + config — see §4, "ArgoCD manages ArgoCD"
02-argocd-apps/ argocd-apps's install, its AppProjects, the tenant-onboarding ApplicationSet
10-crds-operators/ Crossplane + Providers/Configurations, cert-manager, ESO, ingress
20-service-catalog/ the idp-service-catalog XRDs/Compositions, pinned to a version
30-policy/ Kyverno policies, cluster-wide guardrails
40-observability/ Prometheus/Grafana/Tempo/Loki stack
50-platform-cicd/ dev cluster only — Tekton, PaC, the CDEvents broker (see §7)
Numeric prefixes are ordering hints for humans reading the repo, not a dependency
mechanism ArgoCD enforces — real ordering (e.g. CRDs before anything that uses them)
still needs sync-wave annotations where it matters, same as any app-of-apps setup.
02-argocd-apps/’s tenant-onboarding ApplicationSet resource lives here, but its
generator reads from a separate gitops-cluster-<name>-tenants repo, not a local
directory — see §1’s “Splitting static cluster config from app onboarding” for why.
4. “Let ArgoCD manage ArgoCD” — the bootstrap sequence
One manual step per new cluster, reusing the hack/bootstrap-upper-cluster.sh precedent
from platform-cicd: install argocd-platform via script, then create a single root
Application (“self”) pointed at that cluster’s own 01-argocd-platform/ directory,
which contains argocd-platform’s own Helm values and Application manifest. From that
point on, upgrading argocd-platform itself — version bumps, RBAC changes, resource
limits — is an ordinary PR to gitops-cluster-<name>, not a manual helm upgrade.
argocd-platform’s app-of-apps root also owns 02-argocd-apps/ — so argocd-apps is
itself entirely GitOps-managed, but managed by cluster admins, never by itself and
never by app owners. This is the concrete mechanism behind “cluster admins manage
cluster config, app owners manage their own app” holding even for the platform’s own
control plane: an app owner has no path, accidental or otherwise, to alter argocd-apps’s
RBAC, because that file lives in a repo they have no write access to.
5. Per-app delivery: one Application, one namespace, N clusters (requirements 4, 5)
Reuses the two-source Application pattern from platform-cicd’s tenant-onboarding
ApplicationSet (charts/platform-cicd-control-plane/templates/argocd/ tenant-onboarding-applicationset.yaml), live-attack-tested there: helm.valuesObject
(operator-owned, source 0) takes real precedence over helm.valueFiles (developer-owned,
source 1) for any overlapping key — confirmed live, zero effect from a planted hostile
override.
Generalized for the IDP:
-
Source 0:
idp-service-catalog’s shared chart, pinned to a version, with an operator-ownedvaluesObject— namespace, app identity, which cluster/env this rendering targets. -
Source 1:
$ref-only into the app’s owngitops-<app-name>repo,directory: {exclude: "*"}(closes a real gotchaplatform-cicdhit live — a$ref-only source is still auto-detected as a plain directory source and applies every YAML in it unless explicitly excluded),valueFiles: [$appsrc/<cluster>/<env>/ values.yaml]. -
Source 2 (optional, added 2026-08-18): a Kustomize
configMapGeneratoratgitops-<app-name>/<cluster>/<env>/configmap/— the ApplicationSet sourceidp-application’s ownconfigMaps: existingConfigMap:field (§ service-catalog- design.md’s Item onconfigMaps:/secrets:) was built to consume, for apps with many config files where hand-writingconfigMaps: data:invalues.yamldoesn’t scale. Same repo as Source 1 (no newAppProject.sourceReposentry needed), samedisableNameSuffixHash: truefixed-name convention that field’s own design settled on. Opt-in only, via an optionalconfigMapGenerator: truefield on the per-envidentity.yaml— most apps never set it, and the mechanism is built specifically so they’re unaffected either way.Can’t be a plain
{{ if }}around a 3rdsourcesentry insidetemplate:— confirmed against ArgoCD’s own docs and source: even withgoTemplate: true, templating is applied per string field after the YAML is already parsed, never to the whole document as text the way Helm does, so control flow can’t add or remove array elements there. The real mechanism isspec.templatePatch— a separate string field that is rendered as one whole Go-template document, then merged onto the generatedApplicationviastrategicpatch.StrategicMergePatch. SinceApplicationSpec.Sourcescarries nopatchMergeKey, any patch that touchessourcesat all replaces it wholesale — so the patch repeats sources 0 and 1 verbatim alongside the new source 2, rather than trying to append just one entry. The{{ if }}-false branch renders to a literal{}, not an empty/absent block — confirmed live (a local Go harness against the realstrategicpatchpackage) that a barespec:with nothing under it parses tospec: null, and merging that wipessourcesanddestinationon every tenant that didn’t opt in, not just leaving them unset.{}is the verified-safe no-op. See either cluster repo’s owntenant-onboarding/applicationset.yamlfor the implementation (the dev cluster’s carries the full rationale in its header; the prod cluster’s points at it).
gitops-<app-name> layout — upper environments only, see §10 for where lower/
ephemeral envs live instead:
gitops-<app-name>/
prod-1/staging/values.yaml
prod-2/prod/values.yaml
One values.yaml per cluster×env an app actually targets — satisfies requirement 4 (an
app can deploy to more than one cluster) structurally: adding a cluster is adding a
directory, not restructuring anything.
One Application, one namespace, bundled supporting components (requirement 5): the
shared chart’s templates render everything for one app+env — the app’s own Deployment/
Service, and any Crossplane Claims for components the app owns the lifecycle of (a
cache, an auth sidecar’s config) — as part of the same Helm release, into the same
app-<name>-<env> namespace (naming convention unchanged from platform-cicd’s
docs/naming-conventions.md). Nothing here creates a second Application per component;
“supporting components are part of the application” is enforced by them being template
outputs of one chart, not a separate onboarding path.
6. AppProject scoping — closing a known gap, not just repeating it
platform-cicd’s docs/multi-cluster.md names an explicit, deliberate gap: a
cluster-mapped release’s Application uses project: default rather than a real scoped
AppProject, because that piece wasn’t built yet. This design closes it from day one,
since a fleet of many prod clusters is exactly where an unscoped project becomes a real
risk rather than a theoretical one: every app onboarded via the argocd-apps
ApplicationSet gets its own AppProject —
sourceRepos:idp-service-catalog+ that app’s owngitops-<app-name>only.destinations: that app’s ownapp-<name>-<env>namespace(s) on this cluster only.
Reuses the exact boundary-enforcement pattern already live-tested in platform-cicd
(a throwaway Application pointed outside an AppProject’s sourceRepos was created at
the API level but ArgoCD refused to reconcile it — real rejection, confirmed live, not
just scoped-looking YAML).
This is the upper-env AppProject. §10 defines a second, separately-scoped
AppProject per app for lower/ephemeral environments on the dev cluster — same
enforcement mechanism, deliberately different sourceRepos/destinations.
7. Where platform-cicd fits
platform-cicd’s own control plane (Tekton, Pipelines-as-Code, the CDEvents broker)
keeps running on the dev cluster, as 50-platform-cicd/ inside gitops-cluster-dev’s
own logical groups — it becomes cluster config like everything else, GitOps-managed by
argocd-platform rather than the ad hoc helm upgrade steps hack/bootstrap.sh uses
today. Its own tenant-onboarding ApplicationSet (tenants/*/identity.yaml, driving
argocd-apps on the dev cluster specifically) is the direct ancestor of §5’s pattern —
this doc generalizes it to N clusters, it doesn’t replace its mechanism.
The multi-cluster release/outcome loop already built there (sync hooks → argocd-outcome- relay → CDEvents broker, docs/multi-cluster.md §“the feedback relay”) is the same
event-driven backbone this doc assumes for every cluster, not just upper-env releases —
see “Forward-looking, not built here” below.
8. Chart delivery: git source, not OCI, for now
platform-cicd already investigated packaging its own charts as OCI images for ArgoCD
to pull (platform_cicd_session_oci_chart_investigation) and found a real, confirmed
ArgoCD limitation: semver-range targetRevision resolution doesn’t work against OCI
sources at all (argoproj/argo-cd#9528 — the resolver needs an index.yaml, which OCI
registries don’t have). Exact-version pins work fine over OCI; floating ranges don’t.
idp-service-catalog and idp-cluster-baseline both use git source, pinned to a tag,
for the same reason platform-cicd reverted to git: the property actually wanted here
(“publish, ArgoCD tracks it, no manual re-pin for active development”) is what a git
source already gives natively. Revisit OCI + exact pins together with platform-cicd’s
already-flagged follow-up (“a real, proper, versioned/tested/promoted release workflow”)
rather than solving it twice.
idp-cluster-baseline specifically: Helm chart, not a Kustomize remote base (resolved)
This is the direct replacement for the “common cluster repo pulled in as a Kustomize remote resource” piece of your prior GitOps strategy — same role (one shared source for common cluster config, patched per cluster), different delivery mechanism. Worth comparing directly, since the DRY property that motivated the original design matters here too.
What a Kustomize remote base gets you
(resources: [https://github.com/org/idp-cluster-baseline//base?ref=v1.2.3]): no
packaging or publish step at all — any commit, tag, or branch is directly addressable —
and a patch can override any field in the rendered output, regardless of whether the
upstream base exposed a values-style hook for it. That flexibility is real, and Helm
doesn’t fully match it: a Helm chart consumer is limited to whatever the chart author
exposed in values.yaml.
What it costs — matching what you already ran into: kustomize build resolving a
remote base does a fresh clone/fetch of the remote repo on every render, which is
genuinely slow at ArgoCD’s reconciliation cadence, and gets worse as more Applications
reference it (every cluster’s argocd-platform, on every poll/webhook cycle). There’s a
second, structural problem beyond raw speed: ArgoCD’s repo-server polls/webhooks the
Application’s own source.repoURL — it has no visibility into a Kustomize base’s
transitively-referenced remote repo, so a change to idp-cluster-baseline pinned by
branch (not tag) may not get picked up until ArgoCD’s own periodic full cache refresh,
not immediately the way a direct repo change is. (I haven’t verified this specific
behavior live against a real ArgoCD install — flagging it as “matches known ArgoCD/
Kustomize behavior, worth a quick live check before fully committing,” the same way the
sync-hook-vs-Notifications question in platform-cicd got settled with a real test
rather than an assumption.)
The reason to prefer Helm here isn’t just speed: this whole design already leans on
one specific Helm property as a structural safety mechanism —
helm.valuesObject (operator) beating helm.valueFiles (developer) for overlapping
keys, live-attack-tested in platform-cicd, and reused for §5 (app delivery). Kustomize’s
patch model doesn’t give you that guarantee for free — override-safety has to be reasoned
about per patch, per field, rather than coming structurally from the tool. Standardizing
on one common-config-plus-overlay mechanism for the whole platform (apps and clusters
both) is worth more than Kustomize’s extra override flexibility, especially since that
flexibility gap has a cheap answer: give idp-cluster-baseline’s chart(s) a deliberate
escape-hatch value (an extraManifests:-style list — a common Helm-chart pattern) for
the rare case a cluster needs something the values schema doesn’t expose, rather than
reaching for a second templating tool to solve it.
Recommendation, confirmed: Helm chart, git-source pinned tag, each cluster repo
supplies its own values.yaml override. If a real per-cluster need for
patch-arbitrary-field flexibility shows up in practice, a local (non-remote, no
network fetch, no staleness risk) Kustomize overlay inside that one cluster’s own repo,
patching the already-rendered output, is a reasonable escape hatch — the problem with
the prior setup was specifically the remote fetch, not Kustomize as a tool.
CI gates for cluster config: lint/syntax only, human review carries the real weight (resolved)
idp-cluster-baseline and gitops-cluster-<name> both get PR review as the actual gate,
plus lint/syntax CI — not a governance-check suite like platform-cicd’s release stage
(SAST/image-scan/policy-check/SBOM don’t have an equivalent meaning for cluster config
the way they do for an app image). Concretely: helm lint, yamllint, helm template
rendered against each cluster’s real values as a dry run (catches render errors before
merge, not just syntax), and kubeconform/kubeval against the rendered output for
schema validity. Required reviewers + branch protection on both repos, same as
everywhere else in this design — the review is the real gate, CI just keeps obviously-
broken YAML from reaching that review.
9. Credentials
Two rules carried forward, both already established preferences, not new ones:
- No standing cross-cluster credential, ever (§ “Guiding constraint” above).
- Never-persisted beats cached-and-refreshed, even when refreshing would be simpler
([[feedback_credential_persistence_preference]]) —
platform-cicddeferred wiring ESO for its outcome-relay tokens specifically because the right home for thatExternalSecretis “whenever this app’s k8s delivery becomes a proper Helm chart.”idp-service-catalog(§1) is that chart. Wiring ESO-based relay-token distribution there, once the chart exists, closes that deferred item as a side effect of this work rather than a separately-scheduled task.
10. Lower vs. upper environments — a real security boundary, not just a naming split
Built and live-verified 2026-08-16, prompted by a real bug: a NodeJSApplication
(nodejs-demo-app) onboarded with cicd.yaml declaring
deploy.lowerEnvironments: [dev], and nothing ever provisioned that namespace -
ApplicationEnvironment structurally refuses dev-cluster targets by design (this
section’s whole point), so platform-cicd’s chart-rendered Role/RoleBinding for
that env sat permanently OutOfSync (namespaces "app-nodejs-demo-app-dev" not found). One real correction to how this section is phrased below: “argocd-apps…
gets a second ApplicationSet” reads as one platform-wide ApplicationSet - not
achievable, since a git files generator only ever has one static repoURL, and
each app’s platform/envs/ lives in a different repo. What’s actually built is
one ApplicationSet per app, generated dynamically by
gitops-cluster-dev/02-argocd-apps/tenant-appprojects’s own chart (it already fires
once per app) alongside the second, narrower AppProject this section calls for -
same “render a resource ApplicationSet can’t generate directly via an Application +
chart” trick already used for the per-app AppProject, extended one level further.
Live-verified: both new objects (<app>-lower AppProject,
<app>-lower-envs ApplicationSet) sync healthy per app; the AppProject
rejection boundary was proven with two throwaway Applications (one pointed at
gitopsRepoUrl, deliberately excluded from sourceRepos; one pointed at a
non-matching destination.namespace) - both hard-rejected with a clear
InvalidSpecError, not just shaped like the right YAML. Caveat, not fully
tested: the cross-cluster half of this boundary (pointing a lower-env
Application at a prod namespace) is currently structurally unreachable rather than
AppProject-tested - neither ArgoCD instance in this fleet has any other cluster
registered as a destination (argocd.argoproj.io/secret-type: cluster) today, so
there’s nothing to actually attempt this against yet.
A real platform/envs/dev.yaml committed to nodejs-demo-app’s own repo (playing
the “developer self-serves” role this section describes) closed the original bug
end to end: app-nodejs-demo-app-dev got provisioned for real (ServiceAccount +
NetworkPolicy, Synced/Healthy), and the previously-stuck Role/RoleBinding
in that Application’s sibling CICD-onboarding Application (a separate ArgoCD
project entirely, platform-cicd’s own tenant-onboarding) now has a real
namespace to sync into - confirmed by re-checking it directly, though by the time
this landed, an unrelated edit to that app’s own cicd.yaml had already dropped
dev from lowerEnvironments, which independently cleared the original symptom
too. The mechanism itself was still verified for real, not inferred from that
coincidence: namespace creation, AppProject/ApplicationSet health, and the
rejection tests above all confirm it end to end.
Three real bugs found live getting the platform/envs/*.yaml → idp-application
plumbing working, none obvious from ArgoCD’s docs alone:
- The git
filesgenerator’spathparam is a map, not a string.{{.path}}alone (a first attempt at “the matched file’s directory”) renders Go’s default map stringification (map[basename:... filename:dev.yaml path:platform/envs segments:[platform envs]]) into theApplicationspec, not a clean path - real, liveComparisonError.{{.path.path}}is the map’s own directory-string field; concatenated with{{.path.filename}}(the bare matched filename) gives the real full path avalueFiles$refneeds ($appsrc/{{.path.path}}/{{.path.filename}}) - there’s no single convenience field for the whole thing. env:collides withidp-application’s own reservedenv:key. The self-describing field this tier’s own files use to name themselves (mirroring<env>/identity.yaml’senv:convention) can’t be calledenv:here, because unlikeidentity.yamlthis file also gets fed straight intoidp-application’s Helm values as avalueFilesentry - whereenv:already means the chart’s Embedded-tier container env-var list. A realenv: devproduced a liverange can't iterate over devHelm error inside that chart’sworkloadEnvhelper.envName:(matching the chart’s own field name - itsvalues.yamlalready documents choosing that name for exactly this reason) is correct instead.- The chart’s own
rollout:default isn’tnull. Leavingrollout:out ofplatform/envs/dev.yamlentirely (intending the same “no image yet” bootstrap stubApplicationEnvironmentuses) let the chart’s own defaultrollout:value (non-null, emptyimage.repository/tag) render a realRolloutwith twoInvalidImageNamepods.rollout: nullhas to be explicit.
Your instinct here is right, and it’s a direct extension of a boundary this doc already
draws for a different reason: gitops-<app-name> (§5) is the reviewed, release-gated
path to real users — it should carry upper environments only. Lower environments (a
persistent dev, plus short-lived ad hoc test envs) don’t belong there; they’re a
developer’s own sandbox and shouldn’t share a repo, a review bar, or an AppProject with
what actually reaches staging/prod.
Where lower envs live: the app’s own /platform folder, live-read
platform-cicd’s own design already anticipated this exact scenario — the tracked-copy
→ live-read redesign for cicd.yaml (platform_cicd_session_argocd_onboarding) was
explicitly justified by “a planned self-service folder in each app repo (developer-
authored Crossplane XRs/CRs) means ArgoCD needs broad app-repo read access anyway.” This
is that folder doing exactly the job it was justified for. Concretely: argocd-apps on
the dev cluster only gets a second ApplicationSet, generating one Application per
app that live-reads <app-name>/platform/envs/*.yaml directly from the app’s own src
repo — no gitops repo involved for this tier at all. A developer adds or edits a file
under platform/envs/ and pushes; no PR-based release pipeline, no governance checks, no
gitops-<app-name> commit.
This isn’t a new mechanism bolted on — it’s the same shape as the PR-based ephemeral
environments already built and live in platform-cicd
(docs/ephemeral-environments.md: an ArgoCD ApplicationSet pullRequest generator that
already watches the app’s own repo’s PRs directly, not a gitops repo, to spin up a
preview namespace per open PR). That generator is a special case of this tier, not a
separate thing: a PR-triggered ephemeral env and a developer-defined ad hoc ephemeral env
both land in the same lower-env AppProject below — one triggered by a PR event, the
other by a direct commit to platform/envs/.
The enforced boundary: a separate AppProject, not just a separate folder
The split has to be structural, the same way §6 makes upper-env scoping structural
rather than conventional. A second AppProject per app, scoped narrower on
sourceRepos but destined only for the dev cluster:
sourceRepos: that app’s own src repo (notgitops-<app-name>) +idp-service-catalog.destinations: dev cluster only, and only that app’s own-dev/-pr-<n>/ad hoc ephemeral namespaces (naming convention unchanged —platform-cicd’sdocs/naming-conventions.md) — never a staging/prod namespace, on any cluster, even by mistake. This is the one property worth stress-testing live once this is built, the same way the AppProject-rejection boundary was stress-tested inplatform-cicd: try to point a lower-envApplicationat a prod namespace and confirm ArgoCD refuses it.
A developer committing straight to platform/envs/ can create or modify their own
lower-env definitions freely — the safety property isn’t “developers can’t self-serve,”
it’s “self-service is fenced to a namespace class that can structurally never be
mistaken for, or escalated into, a real environment.”
What this doc doesn’t decide (service-catalog scope, goal 8)
Two follow-on questions surfaced by this split belong to the XRD/Composition design, not this doc:
- Whether a lower-env Claim (e.g. “give me a database”) should resolve to a cheaper/ lighter Composition than the same Claim kind would in an upper env — likely yes, but that’s a Composition-authoring decision, not a GitOps-topology one.
- TTL/cleanup for a developer-triggered ad hoc env that isn’t tied to a PR’s lifecycle.
platform-cicdalready has a marker for this class of problem (platform.io/ephemeral-env, currently swept only for PR-namespace TTL perdocs/naming-conventions.md) — extending that same sweep to cover ad hoc envs looks like the natural fit, not a new mechanism, but isn’t designed here.
Forward-looking, not designed here
Two places this doc deliberately stops short, both flagged so the GitOps layer doesn’t paint them into a corner later:
- Where Crossplane itself runs — resolved 2026-08-15, see
service-catalog-design.md§0 “Where Crossplane runs across a multi-cluster fleet” for the full reasoning. Neither hub-and-spoke nor uniform per-cluster: it’s tier-dependent. Bootstrap-tier XRDs (NodeJSApplication/ApplicationEnvironment) centralize on one dev cluster permanently, regardless of fleet size, since they only ever write git commits (provider-github), never touch any cluster’s K8s API. Attached-tier XRDs (SLOand friends) genuinely need10-crds-operators/’s original per-cluster assumption, since they compose native in-cluster resources directly — no meaning without Crossplane actually running where the resource lands. A new cluster-admin-owned cluster registry (a labeledConfigMapper cluster,type: dev|upper+ readiness flags) is what makes a per-app cluster choice (NodeJSApplication.spec.devCluster,ApplicationEnvironment.spec.cluster) checkable against real fleet state. - AI-triage hooks (goal 9) — partially resolved 2026-08-15. The sync-hook → relay →
CDEvents pattern proven for release outcomes (§7) remains the right structural fit,
still not built. Separately,
service-catalog-design.md§0 resolves where the existingfunction-rollout-watcher/HolmesGPT mechanism (already built, [[idp_session_phase2_holmesgpt]]) needs to run in a multi-cluster fleet — same per-cluster requirement as Attached-tier XRDs, plus a real redesign (extra-resources watch of an already-Helm-created Rollout, not same-XR composition) needed regardless of cluster count. Whether Holmes itself runs per-cluster or stays a shared instance with per-cluster-scoped credentials is flagged there as its own still-open follow-on.
Open questions for discussion
Resolved in review round 1 (2026-08-12) — kept here as a changelog, not because
they’re still open: label namespace stays platform.io/* for the whole IDP; ArgoCD
instance names (argocd-platform/argocd-apps) approved; cluster-config repo shape is
per-cluster, not a fleet monorepo (§1); idp-cluster-baseline is a Helm chart, not a
Kustomize remote base (§8); CI gates for cluster config are lint/syntax + human review,
not a governance suite (§8); gitops-<app-name> carries upper environments only, with
lower/ephemeral envs moved to a separately-scoped path (§10); tenants repo is
per-cluster, not fleet-wide (§1).
Still open:
- Lower-env Composition tiering and ad hoc-env TTL sweep (§10) — parked under “Forward-looking,” belongs to the service-catalog design (goal 8), not this doc.
All structural questions from the first draft are now resolved. Next up: the service catalog / Crossplane XRD design (goal 8) is the natural next piece — see README.md’s status section.