From ee7fcd979789d26cdeeb60264034db41b902dde1 Mon Sep 17 00:00:00 2001 From: stxkxs <139715017+stxkxs@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:29:47 -0700 Subject: [PATCH 1/3] feat: ship the tenant boundary appset in the default install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal renders charts/tenant and commits one manifest per tenant to tenants//.yaml in the tenants GitOps repo. The ApplicationSet that applies those commits is what turns a portal write into a Platform CR on a cluster; without it, portal-created tenants never reach the cluster at all. It sat under applicationsets/opt-in/, which app-of-apps sources without directory.recurse and therefore never applies. So on a cluster built by cluster-bootstrap with tenants_repo_url set, the read-only deploy key for the tenants repo was registered in ArgoCD and nothing was installed that reads it — a credential provisioned for a consumer that does not exist. Enabling the tenant path meant an out-of-band apply, which put a manual step in the middle of an otherwise declarative install. The reason it was withheld is real: it named an org. An appset that hardcodes git@github.com:/... cannot be correct in a fork, and one whose git generator reaches for a private repo it cannot see fails to generate, goes ErrorOccurred, and drags app-of-apps to Degraded. That argument applies to naming an org, not to the appset. So the org is no longer named. Both halves come from the cluster Secret, the same way gitops/repo-url already lets every addon appset template its own source: annotation gitops/tenants-repo-url the repo the portal pushes to label portal/tenants-enabled opts the cluster in A cluster that has not opted in matches nothing in the clusters generator, so the child git generator never runs and the appset sits healthy with no children. Nothing reaches for a repo it cannot see, which is the condition that kept it out of the default path. The generators are now a matrix — clusters first, git second, because a matrix child that consumes parameters must come after the one that produces them. That ordering also buys the routing the single git generator could not express: the discovered path is tenants/ rather than a tenants/* glob, and the destination is the matched cluster rather than a fixed in-cluster address. A glob crossed with the clusters generator would apply every cluster's tenants to every cluster; naming the directory after the cluster means each one sees only its own, and the appset is correct on an ArgoCD with more than one cluster registered. cluster_name carries the full EKS cluster name (cluster-bootstrap wires it from the cluster component's output, which is -), so it matches the directory the portal writes. goTemplateOptions keeps missingkey=error, so a cluster labelled without its annotation fails to render rather than silently generating nothing. The two are stamped under one condition, alongside the deploy key, so they cannot arrive apart. The fork-safety gate now scans this appset — it only reads the applied set, and opt-in/ is excluded by construction. --- README.md | 4 +- applicationsets/opt-in/README.md | 25 ++++-- applicationsets/opt-in/portal-tenants.yaml | 69 ----------------- applicationsets/portal-tenants.yaml | 88 ++++++++++++++++++++++ docs/architecture/overview.md | 2 +- 5 files changed, 111 insertions(+), 77 deletions(-) delete mode 100644 applicationsets/opt-in/portal-tenants.yaml create mode 100644 applicationsets/portal-tenants.yaml diff --git a/README.md b/README.md index 7c686b7..63b0036 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ repoURL: '{{ index .metadata.annotations "gitops/repo-url" }}' └─────────────────────────────────────────────────────────────────────┘ Plus opt-in ApplicationSets under `applicationsets/opt-in/` (`apps-tenants`, -`clusters-appset`, `portal-tenants`) — not applied by a default install; see +`clusters-appset`) — not applied by a default install; see [`applicationsets/opt-in/README.md`](applicationsets/opt-in/README.md). ``` @@ -90,6 +90,8 @@ eks-gitops/ │ ├── gateway-api-crds.yaml │ ├── secret-stores.yaml │ ├── dashboards.yaml +│ ├── portal-tenants.yaml # tenant boundary CRs from the tenants repo +│ │ # (label-gated; repo URL from the cluster Secret) │ └── opt-in/ # NOT applied by default (apps/clusters/portal) │ ├── addons/ # Addon configurations diff --git a/applicationsets/opt-in/README.md b/applicationsets/opt-in/README.md index 69d1744..1affcba 100644 --- a/applicationsets/opt-in/README.md +++ b/applicationsets/opt-in/README.md @@ -14,18 +14,25 @@ that could never generate, leaving them permanently `ErrorOccurred` and dragging | ApplicationSet | Requires | |---|---| | `clusters-appset.yaml` | The private clusters repo over SSH, **and the hub cluster** — it applies eks-fleet `Cluster` CRs, which only exist where the Cluster API + Crossplane run. On a workload/spoke cluster it can never be correct. | -| `portal-tenants.yaml` | The private tenants repo over SSH, plus an ArgoCD SSH repo credential (a read-only deploy key) registered for that URL. | | `apps-tenants.yaml` | One `AppProject` per tenant app (the template sets `project: {{ .app }}`), plus read access to each app's source repo. | +The tenant boundary appset is not one of these — it ships in the default path at +[`../portal-tenants.yaml`](../portal-tenants.yaml). It reads its repo URL from a +cluster annotation and gates on a cluster label, so on a cluster that has not +opted in the clusters generator matches nothing, its child git generator never +runs, and the appset sits healthy with no children. Nothing reaches for a +private repo it cannot see, which is the condition this folder exists to avoid. +An appset a label can make inert does not need to be withheld from the default +path. + ## Enabling them They are ordinary ApplicationSets — nothing about them is special beyond the prerequisites above. To turn them on for an install that genuinely has the portal, the private repos, and (for `clusters-appset`) the hub: -1. Register the SSH repo credentials in ArgoCD for the clusters/tenants repos - (a read-only deploy key per repo). `cluster-bootstrap` does this when - `tenants_repo_url` is set. +1. Register the SSH repo credential in ArgoCD for the clusters repo (a read-only + deploy key). 2. Create the per-app `AppProject`s that `apps-tenants` references. 3. Point an Application at this path — either add a second app-of-apps whose source `path` is `applicationsets/opt-in`, or set `directory.recurse` on the @@ -33,7 +40,13 @@ portal, the private repos, and (for `clusters-appset`) the hub: ## Before enabling, re-check the hardcoded repo URLs -`clusters-appset.yaml` and `portal-tenants.yaml` hardcode `git@github.com:` URLs, -and `apps-tenants.yaml` hardcodes its four app source repos. An install in a +`clusters-appset.yaml` hardcodes a `git@github.com:` URL, and +`apps-tenants.yaml` hardcodes its four app source repos. An install in a different GitHub org must repoint these — they are not templated from the cluster Secret today. + +That is the difference between these two and `portal-tenants`: naming an org in +the manifest is what forces an appset to be withheld from the default install, +because there is no per-cluster value that can make it correct. Templating the +URL off the cluster Secret, the way `gitops/repo-url` already works, is the way +out of this folder. diff --git a/applicationsets/opt-in/portal-tenants.yaml b/applicationsets/opt-in/portal-tenants.yaml deleted file mode 100644 index 546121b..0000000 --- a/applicationsets/opt-in/portal-tenants.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# Applies the tenant boundary manifests the portal commits to the tenants -# GitOps repo. The portal's write path renders charts/tenant (Platform + -# BudgetPolicy + Tenant + agent-plane CRs) and commits one manifest per tenant -# to tenants//.yaml; without this ApplicationSet nothing -# applies those commits, so portal-created tenants never reach the cluster. -# -# A git directory generator discovers each per-cluster directory and recurses -# its manifests (robust — no per-file content parsing). repoURL MUST equal the -# portal worker's GITOPS_TENANTS_REPO_URL — both the SSH form, since the portal -# pushes over SSH (its git layer is SSH-only). ArgoCD needs a matching SSH repo -# credential (a read-only deploy key on the private tenants repo) registered for -# this URL so it can pull what the portal pushes. -# -# NOTE: static-validated only (no live EKS run yet). Verify on first use, and -# consider two refinements: per-tenant granularity (a git files generator over -# tenants/*/*.yaml for one Application per tenant) and multi-cluster routing -# (match the path segment to a registered cluster via a -# matrix+clusters generator) instead of applying to the in-cluster destination. -apiVersion: argoproj.io/v1alpha1 -kind: ApplicationSet -metadata: - name: portal-tenants - namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "100" -spec: - goTemplate: true - goTemplateOptions: ["missingkey=error"] - generators: - - git: - repoURL: git@github.com:nanohype/tenants.git # == portal GITOPS_TENANTS_REPO_URL (SSH) - revision: main - directories: - - path: tenants/* - template: - metadata: - name: 'portal-tenants-{{ .path.basename }}' - # A git-directories generator carries no cluster Secret labels, so the - # standard block omits the per-cluster environment label the addon appsets - # stamp. - labels: - app.kubernetes.io/managed-by: argocd - app.kubernetes.io/component: portal-tenant - platform.nanohype.dev/repository: nanohype/eks-gitops - platform.nanohype.dev/team: platform-engineering - annotations: - argocd.argoproj.io/sync-wave: "100" - spec: - project: platform - source: - repoURL: git@github.com:nanohype/tenants.git - targetRevision: main - path: '{{ .path.path }}' - directory: - recurse: true - destination: - server: https://kubernetes.default.svc - syncPolicy: - automated: - prune: true - selfHeal: true - syncOptions: - - ServerSideApply=true - retry: - limit: 5 - backoff: - duration: 5s - factor: 2 - maxDuration: 3m diff --git a/applicationsets/portal-tenants.yaml b/applicationsets/portal-tenants.yaml new file mode 100644 index 0000000..3c39228 --- /dev/null +++ b/applicationsets/portal-tenants.yaml @@ -0,0 +1,88 @@ +# Applies the tenant boundary manifests the portal commits to the tenants +# GitOps repo. The portal's write path renders charts/tenant (Platform + +# BudgetPolicy + Tenant + agent-plane CRs) and commits one manifest per tenant +# to tenants//.yaml; without this ApplicationSet nothing +# applies those commits, so portal-created tenants never reach the cluster. +# +# Both halves of the wiring come from the cluster Secret, so no org is named +# here: +# +# annotation gitops/tenants-repo-url the repo the portal pushes to +# label portal/tenants-enabled opts the cluster in +# +# landing-zone's cluster-bootstrap stamps both under the one condition that +# also registers ArgoCD's read-only deploy key for that repo, so a cluster +# never carries the label without the annotation and the credential that make +# it work. That matters twice over: the deploy key exists to be used by this +# appset, and `missingkey=error` below turns a label stamped without its +# annotation into a render failure rather than a silent no-op. +# +# The git generator reads its repoURL from the first child generator's output — +# the matrix generator passes parameters forward, and a consumer must come +# after its producer, which is why the clusters generator is first. +# +# Scoping the discovered path to the cluster's own name rather than globbing +# tenants/* is what makes this correct on an ArgoCD with more than one cluster +# registered. A tenants/* glob crossed with the clusters generator would apply +# every cluster's tenants to every cluster; naming the directory after the +# cluster means each one sees only its own, and the destination is that same +# cluster rather than a fixed in-cluster address. +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: portal-tenants + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "100" +spec: + goTemplate: true + goTemplateOptions: ["missingkey=error"] + generators: + - matrix: + generators: + - clusters: + selector: + matchLabels: + argocd.argoproj.io/secret-type: cluster + portal/tenants-enabled: "true" + # A directory that does not exist yet yields no parameters, so a + # cluster opted in before its first tenant is committed renders + # nothing rather than erroring. + - git: + repoURL: '{{ index .metadata.annotations "gitops/tenants-repo-url" }}' + revision: main + directories: + - path: 'tenants/{{ index .metadata.labels "cluster_name" }}' + template: + metadata: + name: 'portal-tenants-{{ .path.basename }}' + labels: + app.kubernetes.io/managed-by: argocd + app.kubernetes.io/component: portal-tenant + platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' + platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/team: platform-engineering + annotations: + argocd.argoproj.io/sync-wave: "100" + spec: + project: platform + source: + repoURL: '{{ index .metadata.annotations "gitops/tenants-repo-url" }}' + targetRevision: main + path: '{{ .path.path }}' + directory: + recurse: true + destination: + server: '{{ .server }}' + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a791575..2b1c19f 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -21,7 +21,7 @@ graph TD ## ApplicationSet Pattern -Most ApplicationSets use the **matrix generator** (addons-agent-operator, clusters-appset, and portal-tenants use other generators) combining: +Most ApplicationSets use the **matrix generator** (addons-agent-operator and clusters-appset use other generators) combining: 1. **Clusters generator** — selects clusters by label `argocd.argoproj.io/secret-type: cluster` 2. **List generator** — defines addons with name, namespace, path, and sync wave From 19cf510049e2776685fedaeb91afdcf761795bf1 Mon Sep 17 00:00:00 2001 From: stxkxs <139715017+stxkxs@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:36:31 -0700 Subject: [PATCH 2/3] fix: render the repository dimension as a label value the API server accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Kubernetes label value is `[A-Za-z0-9]` joined by `-`, `_` or `.`, up to 63 characters. `/` is not in that set — it is legal in a label KEY's prefix and in an AWS tag value, and nowhere else. Every ApplicationSet template stamped the repository dimension as platform.nanohype.dev/repository: nanohype/eks-gitops so every Application the controller rendered was rejected at admission: Application.argoproj.io "cert-manager" is invalid: metadata.labels: Invalid value: "nanohype/eks-gitops": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.' The blast radius is the whole catalog, not the one Application the error names. app-of-apps syncs the ApplicationSets; a generated Application that fails admission fails the sync task; the sync retries five times and reports Failed. So app-of-apps sits OutOfSync/Degraded with zero children and not one addon installs — no cert-manager, no external secrets, no Cilium policies, no operator. The cluster comes up healthy, ArgoCD runs, and the catalog is empty. Observed on a live cluster build, where it stalled a tenant deploy waiting on a cert-manager that could never arrive. The dimension survives intact, rendered per-surface: the AWS tag keeps `nanohype/eks-gitops` (a tag value may contain `/`), and the k8s label carries `nanohype.eks-gitops`. Only the k8s surface changes — the Repository tag on the Karpenter EC2NodeClass is untouched. scripts/check-label-values.py applies the API server's own grammar to every label value in the tracked YAML, so the manifest and the object it becomes cannot disagree. It exists because nothing upstream could see this: an ApplicationSet's spec.template.metadata.labels is `additionalProperties: {type: string}` in the CRD schema, so kubeconform admits any string; yamllint reads syntax; helm and kustomize render text without submitting it; and `kyverno apply` evaluates policy rules rather than API-server field validation. A valid manifest passed every gate and broke on contact with the API server. The gate checks values, not keys, and skips annotations — an annotation value is unconstrained, which is where a genuine org/name slug belongs. Inside a Kyverno policy it reads only the policy's own metadata.labels: `spec.rules[].validate.pattern.metadata.labels` is a matcher over other resources, where `?*` means "any non-empty value" and is correct. A value that is entirely a Go template is skipped as unresolvable here, but the literal text around a template is still checked, so a slash typed beside a placeholder is caught. Wired into the appsets CI job and `task validate`. --- .github/workflows/ci.yml | 9 + Taskfile.yaml | 8 +- applicationsets/addons-agent-operator.yaml | 2 +- .../addons-ai-platform-kustomize.yaml | 2 +- applicationsets/addons-ai-platform.yaml | 2 +- applicationsets/addons-argo-platform.yaml | 2 +- applicationsets/addons-argo-workflows.yaml | 2 +- .../addons-bootstrap-kustomize.yaml | 2 +- applicationsets/addons-bootstrap.yaml | 2 +- applicationsets/addons-external-dns.yaml | 2 +- applicationsets/addons-karpenter.yaml | 2 +- applicationsets/addons-loki.yaml | 2 +- applicationsets/addons-networking.yaml | 2 +- applicationsets/addons-observability.yaml | 2 +- applicationsets/addons-opencost.yaml | 2 +- applicationsets/addons-operations-helm.yaml | 2 +- .../addons-operations-kustomize.yaml | 2 +- applicationsets/addons-otel-agent.yaml | 2 +- .../addons-otel-gateway-floor.yaml | 2 +- applicationsets/addons-otel-gateway.yaml | 2 +- applicationsets/addons-security.yaml | 2 +- applicationsets/addons-tempo.yaml | 2 +- applicationsets/addons-velero.yaml | 2 +- applicationsets/agent-platform.yaml | 2 +- applicationsets/argo-workflows-crds.yaml | 2 +- applicationsets/dashboards.yaml | 2 +- applicationsets/gateway-api-crds.yaml | 2 +- applicationsets/kyverno-policies.yaml | 2 +- applicationsets/opt-in/apps-tenants.yaml | 2 +- applicationsets/opt-in/clusters-appset.yaml | 2 +- applicationsets/portal-tenants.yaml | 2 +- applicationsets/secret-stores.yaml | 2 +- catalog/druid/values.yaml | 2 +- scripts/check-label-values.py | 168 ++++++++++++++++++ 34 files changed, 215 insertions(+), 32 deletions(-) create mode 100755 scripts/check-label-values.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1ec8f..9d939a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,6 +335,15 @@ jobs: - name: Sync-wave ordering gate run: ./scripts/check-sync-waves.py + # An ApplicationSet's spec.template.metadata.labels is a plain string map + # in the CRD schema, so kubeconform admits any value there. It only + # becomes a label when the controller renders an Application, and an + # invalid value fails that Application at admission — which fails the + # app-of-apps sync task, which stops the entire addon catalog from + # installing. Valid manifest, passing schema gate, nothing deployed. + - name: Label-value gate + run: ./scripts/check-label-values.py + # Offline half of the chart-provenance gate: every pinned chart has a # record and every record a pin. Deliberately does not touch the network — # whether a chart went deprecated overnight is not a function of this diff --git a/Taskfile.yaml b/Taskfile.yaml index 01a183d..6023f79 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -80,6 +80,11 @@ tasks: cmds: - ./scripts/check-sync-waves.py + validate:label-values: + desc: "Label-value gate — every k8s label value satisfies the API server's grammar" + cmds: + - ./scripts/check-label-values.py + validate:appset-render: desc: "Appset render gate — render the Karpenter EC2NodeClass patch template against fixture cluster Secrets" dir: applicationsets/rendertest @@ -102,13 +107,14 @@ tasks: - ./scripts/check-athena-panel-columns.py validate: - desc: Run all validations (lint, build, helm-render, appset schema, sync waves, appset render, policy admission, externalsecret keys, athena panel columns, dashboard/fork-safety) + desc: Run all validations (lint, build, helm-render, appset schema, sync waves, label values, appset render, policy admission, externalsecret keys, athena panel columns, dashboard/fork-safety) deps: - lint:yaml - kustomize:build - validate:helm-render - validate:appset-schema - validate:sync-waves + - validate:label-values - validate:appset-render - validate:policy-admission - validate:externalsecret-keys diff --git a/applicationsets/addons-agent-operator.yaml b/applicationsets/addons-agent-operator.yaml index 8376c40..5e8dc17 100644 --- a/applicationsets/addons-agent-operator.yaml +++ b/applicationsets/addons-agent-operator.yaml @@ -37,7 +37,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: eks-agent-platform-operator platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "21" diff --git a/applicationsets/addons-ai-platform-kustomize.yaml b/applicationsets/addons-ai-platform-kustomize.yaml index 7cc4d78..5bad145 100644 --- a/applicationsets/addons-ai-platform-kustomize.yaml +++ b/applicationsets/addons-ai-platform-kustomize.yaml @@ -38,7 +38,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-ai-platform.yaml b/applicationsets/addons-ai-platform.yaml index a70c61d..5fc6bdb 100644 --- a/applicationsets/addons-ai-platform.yaml +++ b/applicationsets/addons-ai-platform.yaml @@ -57,7 +57,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-argo-platform.yaml b/applicationsets/addons-argo-platform.yaml index ef9d20c..f0013da 100644 --- a/applicationsets/addons-argo-platform.yaml +++ b/applicationsets/addons-argo-platform.yaml @@ -44,7 +44,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-argo-workflows.yaml b/applicationsets/addons-argo-workflows.yaml index 09a9823..420e271 100644 --- a/applicationsets/addons-argo-workflows.yaml +++ b/applicationsets/addons-argo-workflows.yaml @@ -55,7 +55,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: argo-workflows platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "52" diff --git a/applicationsets/addons-bootstrap-kustomize.yaml b/applicationsets/addons-bootstrap-kustomize.yaml index 7ccfe7a..6745c23 100644 --- a/applicationsets/addons-bootstrap-kustomize.yaml +++ b/applicationsets/addons-bootstrap-kustomize.yaml @@ -36,7 +36,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-bootstrap.yaml b/applicationsets/addons-bootstrap.yaml index 3da1d48..9c89da8 100644 --- a/applicationsets/addons-bootstrap.yaml +++ b/applicationsets/addons-bootstrap.yaml @@ -59,7 +59,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-external-dns.yaml b/applicationsets/addons-external-dns.yaml index d785cd4..1a9b93b 100644 --- a/applicationsets/addons-external-dns.yaml +++ b/applicationsets/addons-external-dns.yaml @@ -40,7 +40,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: external-dns platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "1" diff --git a/applicationsets/addons-karpenter.yaml b/applicationsets/addons-karpenter.yaml index 9d28cfb..c9fb371 100644 --- a/applicationsets/addons-karpenter.yaml +++ b/applicationsets/addons-karpenter.yaml @@ -47,7 +47,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: karpenter platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "5" diff --git a/applicationsets/addons-loki.yaml b/applicationsets/addons-loki.yaml index 3d7cd6a..09ab943 100644 --- a/applicationsets/addons-loki.yaml +++ b/applicationsets/addons-loki.yaml @@ -31,7 +31,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: loki platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "30" diff --git a/applicationsets/addons-networking.yaml b/applicationsets/addons-networking.yaml index 5876923..3b4f8b1 100644 --- a/applicationsets/addons-networking.yaml +++ b/applicationsets/addons-networking.yaml @@ -44,7 +44,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-observability.yaml b/applicationsets/addons-observability.yaml index 4f5f9f0..d99742d 100644 --- a/applicationsets/addons-observability.yaml +++ b/applicationsets/addons-observability.yaml @@ -52,7 +52,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-opencost.yaml b/applicationsets/addons-opencost.yaml index 4abc437..afae3aa 100644 --- a/applicationsets/addons-opencost.yaml +++ b/applicationsets/addons-opencost.yaml @@ -36,7 +36,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: opencost platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "33" diff --git a/applicationsets/addons-operations-helm.yaml b/applicationsets/addons-operations-helm.yaml index 62c347e..6aa028c 100644 --- a/applicationsets/addons-operations-helm.yaml +++ b/applicationsets/addons-operations-helm.yaml @@ -61,7 +61,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-operations-kustomize.yaml b/applicationsets/addons-operations-kustomize.yaml index d97b951..dcae869 100644 --- a/applicationsets/addons-operations-kustomize.yaml +++ b/applicationsets/addons-operations-kustomize.yaml @@ -34,7 +34,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-otel-agent.yaml b/applicationsets/addons-otel-agent.yaml index f46fac4..c931c8d 100644 --- a/applicationsets/addons-otel-agent.yaml +++ b/applicationsets/addons-otel-agent.yaml @@ -31,7 +31,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: otel-agent platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "32" diff --git a/applicationsets/addons-otel-gateway-floor.yaml b/applicationsets/addons-otel-gateway-floor.yaml index 4c9b302..2617210 100644 --- a/applicationsets/addons-otel-gateway-floor.yaml +++ b/applicationsets/addons-otel-gateway-floor.yaml @@ -32,7 +32,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: otel-gateway-floor platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "32" diff --git a/applicationsets/addons-otel-gateway.yaml b/applicationsets/addons-otel-gateway.yaml index 3f922f6..cb8992e 100644 --- a/applicationsets/addons-otel-gateway.yaml +++ b/applicationsets/addons-otel-gateway.yaml @@ -29,7 +29,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: otel-gateway platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "32" diff --git a/applicationsets/addons-security.yaml b/applicationsets/addons-security.yaml index 1671cff..afe2346 100644 --- a/applicationsets/addons-security.yaml +++ b/applicationsets/addons-security.yaml @@ -51,7 +51,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/addons-tempo.yaml b/applicationsets/addons-tempo.yaml index 80891ce..2a0664b 100644 --- a/applicationsets/addons-tempo.yaml +++ b/applicationsets/addons-tempo.yaml @@ -28,7 +28,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: tempo platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "31" diff --git a/applicationsets/addons-velero.yaml b/applicationsets/addons-velero.yaml index 3c8cfb2..efe4237 100644 --- a/applicationsets/addons-velero.yaml +++ b/applicationsets/addons-velero.yaml @@ -41,7 +41,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: velero platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "40" diff --git a/applicationsets/agent-platform.yaml b/applicationsets/agent-platform.yaml index 12e9fbf..c026808 100644 --- a/applicationsets/agent-platform.yaml +++ b/applicationsets/agent-platform.yaml @@ -47,7 +47,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/argo-workflows-crds.yaml b/applicationsets/argo-workflows-crds.yaml index 8e0dbb7..314c897 100644 --- a/applicationsets/argo-workflows-crds.yaml +++ b/applicationsets/argo-workflows-crds.yaml @@ -73,7 +73,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/dashboards.yaml b/applicationsets/dashboards.yaml index c11357b..6557ecc 100644 --- a/applicationsets/dashboards.yaml +++ b/applicationsets/dashboards.yaml @@ -37,7 +37,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/gateway-api-crds.yaml b/applicationsets/gateway-api-crds.yaml index 5074cf9..81bd85e 100644 --- a/applicationsets/gateway-api-crds.yaml +++ b/applicationsets/gateway-api-crds.yaml @@ -60,7 +60,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/kyverno-policies.yaml b/applicationsets/kyverno-policies.yaml index 5ee4f97..48ce04d 100644 --- a/applicationsets/kyverno-policies.yaml +++ b/applicationsets/kyverno-policies.yaml @@ -47,7 +47,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/applicationsets/opt-in/apps-tenants.yaml b/applicationsets/opt-in/apps-tenants.yaml index 4acff95..8167fc9 100644 --- a/applicationsets/opt-in/apps-tenants.yaml +++ b/applicationsets/opt-in/apps-tenants.yaml @@ -47,7 +47,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .app }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "100" diff --git a/applicationsets/opt-in/clusters-appset.yaml b/applicationsets/opt-in/clusters-appset.yaml index 90cc272..4fde75e 100644 --- a/applicationsets/opt-in/clusters-appset.yaml +++ b/applicationsets/opt-in/clusters-appset.yaml @@ -62,7 +62,7 @@ spec: labels: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: cluster - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "100" diff --git a/applicationsets/portal-tenants.yaml b/applicationsets/portal-tenants.yaml index 3c39228..28d7b23 100644 --- a/applicationsets/portal-tenants.yaml +++ b/applicationsets/portal-tenants.yaml @@ -60,7 +60,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: portal-tenant platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: "100" diff --git a/applicationsets/secret-stores.yaml b/applicationsets/secret-stores.yaml index f5fba5f..32097db 100644 --- a/applicationsets/secret-stores.yaml +++ b/applicationsets/secret-stores.yaml @@ -53,7 +53,7 @@ spec: app.kubernetes.io/managed-by: argocd app.kubernetes.io/component: '{{ .appName }}' platform.nanohype.dev/environment: '{{ index .metadata.labels "environment" }}' - platform.nanohype.dev/repository: nanohype/eks-gitops + platform.nanohype.dev/repository: nanohype.eks-gitops platform.nanohype.dev/team: platform-engineering annotations: argocd.argoproj.io/sync-wave: '{{ .syncWave }}' diff --git a/catalog/druid/values.yaml b/catalog/druid/values.yaml index 04cae94..27300da 100644 --- a/catalog/druid/values.yaml +++ b/catalog/druid/values.yaml @@ -17,7 +17,7 @@ version: "36.0.0" # set per tenant/environment above; the org dimensions are constant for this stack. managedBy: "argocd" project: "landing-zone" -repository: "nanohype/eks-gitops" +repository: "nanohype.eks-gitops" team: "data-platform" costCenter: "platform-engineering" businessUnit: "engineering" diff --git a/scripts/check-label-values.py b/scripts/check-label-values.py new file mode 100755 index 0000000..1a611c2 --- /dev/null +++ b/scripts/check-label-values.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Assert every k8s label this repo writes has a value the API server accepts. + +An ApplicationSet's `spec.template.metadata.labels` is a plain string map in the +CRD schema, so nothing about it is a label as far as validation is concerned. +It only becomes one when the ApplicationSet controller renders an Application +and the API server admits it. Every gate upstream of that point sees a +well-formed manifest: + + * kubeconform validates the ApplicationSet against its CRD schema, where the + field is `additionalProperties: {type: string}` — any string passes. + * yamllint reads syntax. + * kustomize/helm render the text without submitting it. + * `kyverno apply` evaluates policy rules, not API-server field validation. + +So a value like an `org/name` repository slug passes the whole pipeline and then +fails at admission, where the message names one generated Application: + + Application.argoproj.io "cert-manager" is invalid: metadata.labels: + Invalid value: "nanohype/eks-gitops": a valid label must be an empty string or + consist of alphanumeric characters, '-', '_' or '.' + +That failure is not scoped to the one Application it names. app-of-apps syncs +the ApplicationSets, an invalid generated Application fails the sync task, and +the whole app-of-apps sync reports Failed — so a single bad label value in a +shared template block stops the entire addon catalog from installing. The +cluster comes up healthy, ArgoCD is running, and nothing is deployed. + +This gate reads the label values directly and applies the API server's own rule, +so the manifest and the thing it becomes cannot disagree. + +Scope: `metadata.labels` and `spec.template.metadata.labels` anywhere in the +tracked YAML. Annotations are deliberately not checked — annotation values are +unconstrained, which is where a genuine `org/name` belongs. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parent.parent + +# The API server's rule, from k8s.io/apimachinery validation: a label value is +# either empty, or up to 63 chars of alphanumerics/'-'/'_'/'.' that starts and +# ends alphanumeric. +LABEL_VALUE = re.compile(r"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$") +MAX_LEN = 63 + +# A value that is entirely a Go template resolves at render time, so its literal +# text is not the value the API server sees and this gate cannot judge it. What +# it CAN judge is the surrounding literal text: `{{ .x }}-suffix` is checked with +# the template elided, so a slash typed next to a placeholder is still caught. +TEMPLATE = re.compile(r"\{\{[^}]*\}\}") + +SKIP_DIRS = {".git", "node_modules", "rendered", "__pycache__", ".task"} + +# Inside a Kyverno policy, `spec.rules[].validate.pattern.metadata.labels` is a +# MATCHER over other resources' labels, not labels being applied — `?*` there +# means "any non-empty value" and is correct. The policy's own top-level +# metadata.labels are real labels and are still checked. +KYVERNO_KINDS = {"ClusterPolicy", "Policy", "ClusterCleanupPolicy", "CleanupPolicy"} + + +def label_blocks(node, path="") -> list[tuple[str, dict]]: + """Yield every (jsonpath, labels-map) pair reachable in a parsed document.""" + found = [] + if isinstance(node, dict): + for key, value in node.items(): + here = f"{path}.{key}" + if key == "labels" and isinstance(value, dict) and path.endswith(".metadata"): + found.append((here, value)) + found.extend(label_blocks(value, here)) + elif isinstance(node, list): + for i, item in enumerate(node): + found.extend(label_blocks(item, f"{path}[{i}]")) + return found + + +def blocks_for(doc) -> list[tuple[str, dict]]: + """Label blocks worth checking in one document.""" + if isinstance(doc, dict) and doc.get("kind") in KYVERNO_KINDS: + own = doc.get("metadata", {}).get("labels") + return [(".metadata.labels", own)] if isinstance(own, dict) else [] + return label_blocks(doc) + + +def check_value(raw) -> str | None: + """Return a human-readable reason the value is invalid, or None.""" + # YAML gives back bools/ints for unquoted true/8080; the API server sees the + # serialized form, and the serialized form of those is always valid. + if not isinstance(raw, str): + return None + + elided = TEMPLATE.sub("", raw) + if elided != raw and elided == "": + return None # pure template — resolves at render time + + if len(elided) > MAX_LEN: + return f"{len(elided)} chars of literal text exceeds the {MAX_LEN}-char limit" + if not LABEL_VALUE.match(elided): + bad = sorted({c for c in elided if not re.match(r"[-A-Za-z0-9_.]", c)}) + if bad: + return "contains " + ", ".join(f"{c!r}" for c in bad) + return "must start and end with an alphanumeric character" + return None + + +def main() -> int: + failures: list[str] = [] + scanned = 0 + + files = sorted( + p + for p in REPO.rglob("*.y*ml") + if not any(part in SKIP_DIRS for part in p.parts) + ) + + for path in files: + try: + docs = list(yaml.safe_load_all(path.read_text())) + except yaml.YAMLError: + # Helm templates and other non-YAML-parseable files are the concern + # of the render gates, not this one. + continue + + rel = path.relative_to(REPO) + for doc in docs: + if doc is None: + continue + for where, labels in blocks_for(doc): + for key, value in labels.items(): + scanned += 1 + reason = check_value(value) + if reason: + failures.append( + f" {rel}\n" + f" at {where.lstrip('.')}\n" + f" {key}: {value!r}\n" + f" -> {reason}" + ) + + print(f"Checked {scanned} label value(s) across {len(files)} YAML file(s)") + + if failures: + print( + f"\n✗ {len(failures)} label value(s) the API server will reject:\n", + file=sys.stderr, + ) + print("\n\n".join(failures), file=sys.stderr) + print( + "\nA label value is [A-Za-z0-9] separated by '-', '_' or '.', max 63 chars.\n" + "'/' is legal in a label KEY's prefix and in an AWS tag value, but never in\n" + "a label value. Render an org/name slug as org.name here and keep the\n" + "slashed form for the AWS tag surface, or move it to an annotation.", + file=sys.stderr, + ) + return 1 + + print("✓ every label value satisfies the API server's grammar") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e8fdbe8eb7001ad693d995453a23f907ba58e7e8 Mon Sep 17 00:00:00 2001 From: stxkxs <139715017+stxkxs@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:45:54 -0700 Subject: [PATCH 3/3] fix: give the tenant boundary appset a sync-wave band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync-wave gate classifies an ApplicationSet by its addons// path, falling back to an explicit map for the ones with no such path. portal-tenants has neither, so the gate could not place it and reported a conflict. It had never needed to: opt-in/ is outside the applied set the gate reads, so an appset parked there is invisible to it, and moving one into the default path is what makes it answerable. Wave 100 is not an arbitrary large number and does not belong in any existing band. The manifests this appset applies declare Platform, Tenant, BudgetPolicy and the agent-plane CRs, so they need the operator's CRDs (ai-platform, 21) installed AND the operator itself running to reconcile them — which transitively means every other category. It is also the one appset whose content comes from outside this catalog, so its contents are not knowable when the wave is chosen. A band of its own at the end states that, where a slot inside apps (50-60) would imply it races the argo-platform workloads. Documented in CLAUDE.md alongside the other bands, which is where the gate's table says it reads them from. --- CLAUDE.md | 6 +++++- scripts/check-sync-waves.py | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ef78ef..a2668fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,11 @@ catalog/ → Platform-specific workloads (Druid) ## Key Conventions ### Sync Waves -Components deploy in order: bootstrap (0, 2) → networking (1) → karpenter (5) → security (10-12) → argo-workflows CRDs (13) → policies (20-23) → observability (29-34) / gateway-api CRDs (30) → operations (40-44) / ai-platform (40-42) → argo-platform (50-52). +Components deploy in order: bootstrap (0, 2) → networking (1) → karpenter (5) → security (10-12) → argo-workflows CRDs (13) → policies (20-23) → observability (29-34) / gateway-api CRDs (30) → operations (40-44) / ai-platform (40-42) → argo-platform (50-52) → tenants (100). + +The tenants band sits alone at the end: `portal-tenants` applies the tenant boundary +manifests the portal commits to the tenants repo, and those declare CRs that need both +the operator's CRDs and a running operator to reconcile them. CRD-only Applications sit immediately ahead of the first thing that renders one of their kinds, not inside a consumer's band. `scripts/check-sync-waves.py` asserts that precedence directly: an ApplicationSet rendering a manifest whose CRD another ApplicationSet installs must sync strictly after it. A controller that merely *watches* a CRD is not a consumer for this purpose — it retries until the kind exists. diff --git a/scripts/check-sync-waves.py b/scripts/check-sync-waves.py index 7d6cab4..29c8299 100755 --- a/scripts/check-sync-waves.py +++ b/scripts/check-sync-waves.py @@ -82,6 +82,12 @@ "ai-platform": (21, 44), "argo-platform": (50, 52), "apps": (50, 60), + # Tenant boundary manifests, applied from the tenants GitOps repo rather + # than from this catalog. They declare Platform/Tenant/BudgetPolicy and the + # agent-plane CRs, so they need both the operator's CRDs (ai-platform, 21) + # and a running operator to reconcile them — which is every other category. + # Hence a band of its own at the end rather than a slot inside apps. + "tenants": (100, 100), } # Documented cross-band exceptions: (category, addon, wave). karpenter is @@ -99,6 +105,7 @@ "agent-platform.yaml": "ai-platform", "addons-agent-operator.yaml": "ai-platform", "dashboards.yaml": "apps", + "portal-tenants.yaml": "tenants", } # C. Which ApplicationSet installs a CRD, and which ones RENDER A MANIFEST of a