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/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/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/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/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/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/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..28d7b23 --- /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/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/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 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()) 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