diff --git a/clusters/_template/bootstrap-kit/11-powerdns.yaml b/clusters/_template/bootstrap-kit/11-powerdns.yaml index 19e2a903..cfd7a46c 100644 --- a/clusters/_template/bootstrap-kit/11-powerdns.yaml +++ b/clusters/_template/bootstrap-kit/11-powerdns.yaml @@ -80,14 +80,25 @@ spec: chart: spec: chart: bp-powerdns - version: 1.1.2 + version: 1.1.3 sourceRef: kind: HelmRepository name: bp-powerdns namespace: flux-system + # disableWait: a Sovereign without bp-cnpg yet reconciled has no + # `pdns-pg-app` Secret (the chart's CNPG Cluster template is gated + # behind the `postgresql.cnpg.io/v1` CRD via Capabilities.APIVersions + # check — see chart/templates/cnpg-cluster.yaml). Without disableWait, + # Helm's `--wait` would hold until the powerdns Deployment is Ready, + # which can't happen until CNPG comes up and synthesises the Secret. + # The HelmRelease itself reports Ready as soon as the manifests apply + # cleanly; runtime convergence (powerdns pods becoming Ready once + # CNPG lands) is observed via kubectl, not gated on Helm. install: + disableWait: true remediation: retries: 3 upgrade: + disableWait: true remediation: retries: 3 diff --git a/clusters/otech.omani.works/bootstrap-kit/11-powerdns.yaml b/clusters/otech.omani.works/bootstrap-kit/11-powerdns.yaml index 90d2aa07..4478f20e 100644 --- a/clusters/otech.omani.works/bootstrap-kit/11-powerdns.yaml +++ b/clusters/otech.omani.works/bootstrap-kit/11-powerdns.yaml @@ -80,14 +80,25 @@ spec: chart: spec: chart: bp-powerdns - version: 1.1.2 + version: 1.1.3 sourceRef: kind: HelmRepository name: bp-powerdns namespace: flux-system + # disableWait: a Sovereign without bp-cnpg yet reconciled has no + # `pdns-pg-app` Secret (the chart's CNPG Cluster template is gated + # behind the `postgresql.cnpg.io/v1` CRD via Capabilities.APIVersions + # check — see chart/templates/cnpg-cluster.yaml). Without disableWait, + # Helm's `--wait` would hold until the powerdns Deployment is Ready, + # which can't happen until CNPG comes up and synthesises the Secret. + # The HelmRelease itself reports Ready as soon as the manifests apply + # cleanly; runtime convergence (powerdns pods becoming Ready once + # CNPG lands) is observed via kubectl, not gated on Helm. install: + disableWait: true remediation: retries: 3 upgrade: + disableWait: true remediation: retries: 3 diff --git a/platform/powerdns/blueprint.yaml b/platform/powerdns/blueprint.yaml index 93c68a3b..b3a40594 100644 --- a/platform/powerdns/blueprint.yaml +++ b/platform/powerdns/blueprint.yaml @@ -6,7 +6,7 @@ metadata: catalyst.openova.io/category: per-host-cluster-infrastructure catalyst.openova.io/section: pts-3-2-gitops-and-iac spec: - version: 1.1.2 + version: 1.1.3 card: title: PowerDNS summary: | diff --git a/platform/powerdns/chart/Chart.yaml b/platform/powerdns/chart/Chart.yaml index aa8b525b..68230117 100644 --- a/platform/powerdns/chart/Chart.yaml +++ b/platform/powerdns/chart/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: bp-powerdns -version: 1.1.2 +version: 1.1.3 description: | Catalyst-curated Blueprint wrapper for PowerDNS Authoritative. Carries Catalyst-specific values.yaml + templates (CNPG cluster, dnsdist diff --git a/platform/powerdns/chart/templates/api-credentials-secret.yaml b/platform/powerdns/chart/templates/api-credentials-secret.yaml index 1e02293f..56e819d0 100644 --- a/platform/powerdns/chart/templates/api-credentials-secret.yaml +++ b/platform/powerdns/chart/templates/api-credentials-secret.yaml @@ -1,34 +1,78 @@ {{- /* PowerDNS REST API + webserver credentials. -Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode) the values flow from -helm install --set-string powerdns.apiKey=,powerdns.webserverPassword= -or, in the production deployment, from an ExternalSecret rendered by the -private-repo cluster manifest at clusters/contabo-mkt/apps/powerdns/. -This template ships a Secret that the upstream chart's deployment.yaml -reads via secretRef indirection (powerdns.api.key.secretRef and -powerdns.webserver.password.secretRef in values.yaml). +Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode) + #10 (credential +hygiene) we MUST never bake plaintext credentials into the chart, BUT we +also MUST give a freshly-installed Sovereign a way to bring its own +PowerDNS up without out-of-band ceremony — a Sovereign that bootstraps +itself from bp-* OCI artifacts has no operator standing by to inject a +Secret while Helm is mid-install. -Bootstrap rendering: the chart REQUIRES values .Values.powerdns.apiKey -and .Values.powerdns.webserverPassword OR an existing -`powerdns-api-credentials` Secret in-cluster (the private-repo -ExternalSecret produces the latter at deploy time). When neither is set, -helm install errors with a useful message via `required`. +The pattern below mirrors bitnami/postgresql, bitnami/keycloak, and the +upstream redis chart: on FIRST install, generate a 32-char random api-key +and 32-char random webserver-password and persist them in the Secret. On +every subsequent reconcile, `lookup` returns the existing Secret and we +re-emit the SAME values — no rotation on upgrade, no drift, no chicken- +and-egg with the deployment. + +The Secret is created BEFORE the Deployment in Helm's normal install +order (alphabetical by kind: Secret < Deployment), so the powerdns pods +find their `powerdns-api-credentials` Secret on first start instead of +sitting in CreateContainerConfigError forever (which is what 1.1.2 did +on otech.omani.works — see PR fixing this). + +Operator override: + - Set `.Values.powerdns.apiKey` and `.Values.powerdns.webserverPassword` + in the cluster overlay to inject specific values (e.g. when a + sealed-secret already pins them). + - Set `.Values.powerdns.useExistingApiSecret: true` to skip Secret + creation entirely and rely on a Secret named `powerdns-api-credentials` + already present in the namespace (created by an out-of-band + ExternalSecret / SealedSecret / etc). */}} -{{- if and (not .Values.powerdns.apiKey) (not .Values.powerdns.useExistingApiSecret) }} -{{- /* Operator must provide the secret out-of-band. Skip Secret creation — - the deployment will fail to start until the named Secret exists, - which is the explicit signal we want. */ -}} -{{- else if .Values.powerdns.apiKey }} +{{- if not .Values.powerdns.useExistingApiSecret }} +{{- $secretName := "powerdns-api-credentials" -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- $apiKey := "" -}} +{{- $webPass := "" -}} +{{- if $existing -}} + {{- /* Reuse what's already there — never rotate on upgrade. */ -}} + {{- $apiKey = index $existing.data "api-key" | b64dec -}} + {{- $webPass = index $existing.data "webserver-password" | b64dec -}} +{{- end -}} +{{- /* Operator-supplied values win over both lookup and randAlphaNum. */ -}} +{{- if .Values.powerdns.apiKey -}} + {{- $apiKey = .Values.powerdns.apiKey -}} +{{- end -}} +{{- if .Values.powerdns.webserverPassword -}} + {{- $webPass = .Values.powerdns.webserverPassword -}} +{{- end -}} +{{- /* Fall back to fresh randoms only when neither lookup nor operator + provided a value (i.e. genuine first install). 32 chars from the + alphanum set per INVIOLABLE-PRINCIPLES #10 (>= 24 chars, no + dictionary words). */ -}} +{{- if not $apiKey -}} + {{- $apiKey = randAlphaNum 32 -}} +{{- end -}} +{{- if not $webPass -}} + {{- $webPass = randAlphaNum 32 -}} +{{- end -}} apiVersion: v1 kind: Secret metadata: - name: powerdns-api-credentials + name: {{ $secretName }} namespace: {{ .Release.Namespace }} labels: {{- include "bp-powerdns.labels" . | nindent 4 }} + annotations: + catalyst.openova.io/comment: | + Generated on first install via helm `lookup` + `randAlphaNum`. On + every subsequent reconcile the existing values are read back so the + Secret is stable across upgrades. Operator may override via + .Values.powerdns.apiKey / .Values.powerdns.webserverPassword in the + cluster overlay. type: Opaque stringData: - api-key: {{ required "powerdns.apiKey is required (random 32+ chars). See INVIOLABLE-PRINCIPLES #10." .Values.powerdns.apiKey | quote }} - webserver-password: {{ required "powerdns.webserverPassword is required." .Values.powerdns.webserverPassword | quote }} + api-key: {{ $apiKey | quote }} + webserver-password: {{ $webPass | quote }} {{- end }} diff --git a/platform/powerdns/chart/values.yaml b/platform/powerdns/chart/values.yaml index eb9220d3..192a216f 100644 --- a/platform/powerdns/chart/values.yaml +++ b/platform/powerdns/chart/values.yaml @@ -39,6 +39,27 @@ catalystBlueprint: # `helm dependency build` resolves the upstream as a subchart; values here # under the `powerdns:` key flow into that subchart unchanged. powerdns: + # ─── Catalyst-only credential injection knobs ─────────────────────── + # Read by templates/api-credentials-secret.yaml (wrapper-level). The + # upstream subchart ignores these keys. + # + # Default behaviour: chart self-generates a 32-char api-key and 32-char + # webserver-password on first install (Helm `lookup` re-uses on every + # subsequent reconcile so the values are stable across upgrades). + # + # Operator overrides: + # apiKey + webserverPassword — pin specific values (e.g. when a + # sealed-secret already encodes them + # for cross-cluster GitOps). + # useExistingApiSecret: true — skip Secret creation entirely; the + # chart assumes a Secret named + # `powerdns-api-credentials` is + # provided out-of-band (ExternalSecret + # / SealedSecret / kubectl create). + apiKey: "" + webserverPassword: "" + useExistingApiSecret: false + # 3 replicas across regions — anycast-fronted public NS endpoints # (per #167 acceptance criteria). Each replica connects to the same CNPG # database; PowerDNS Authoritative is stateless beyond the database. @@ -118,10 +139,13 @@ powerdns: powerdns: # ─── REST API + webserver ─────────────────────────────────────────── # API key + webserver password flow from a Catalyst-managed K8s Secret - # (`powerdns-api-credentials` — see templates/secret.yaml). Per - # INVIOLABLE-PRINCIPLES #4 + #10 the values are NEVER inlined here. - # The upstream chart's secretRef helper takes flat name+key fields - # (see powerdns.secretRef in upstream _helpers.tpl). + # (`powerdns-api-credentials` — see templates/api-credentials-secret.yaml). + # Per INVIOLABLE-PRINCIPLES #4 + #10 the values are NEVER inlined here; + # the wrapper's api-credentials-secret.yaml self-generates 32-char + # randoms on first install and reuses them on every subsequent + # reconcile via Helm `lookup`. The upstream chart's secretRef helper + # takes flat name+key fields (see powerdns.secretRef in upstream + # _helpers.tpl). api: key: name: powerdns-api-credentials @@ -136,6 +160,19 @@ powerdns: name: powerdns-api-credentials key: webserver-password + # ─── Bootstrap zone (DISABLED — Catalyst loads zones via PDM) ────── + # The upstream chart renders a `create-zone-if-not-exists-sh` Job + # that POSTs `zoneName` to /api/v1/servers/localhost/zones at install + # time. Catalyst does NOT use this — the Sovereign's zones are + # provisioned later by pool-domain-manager (PDM) via the same REST + # API after the cluster comes up. Setting `zoneName: ""` short- + # circuits the upstream Job's `{{- if and .Values.powerdns.zoneName + # .Values.powerdns.api.key }}` gate so the Job is never rendered, + # which means the install completes the moment the powerdns + # Deployment is Ready instead of waiting for a Job whose only effect + # is creating an `example.de.` placeholder zone we don't want. + zoneName: "" + # ─── DNS UPDATE (RFC 2136) ────────────────────────────────────────── # Off — Catalyst writes records via the REST API only (cert-manager # webhook + external-dns webhook + crossplane DNS XR all use the