openova/platform/cert-manager-powerdns-webhook
e3mrah 25ef20a8e5
feat(catalyst-chart): land Blueprint CRD + fix 5 string-form depends (slice B4, #1095) (#1112)
Realizes the Blueprint CRD per docs/BLUEPRINT-AUTHORING.md §3 and design
doc §3.2.4. Promotes the doc-contract (apiVersion catalyst.openova.io)
from a YAML-loaded contract to a schema-validated CRD.

Schema design:
- Two versions served from one inline schema (YAML anchors): v1alpha1
  (legacy, served, not storage) and v1 (canonical, served, storage). The
  shared schema means the 38 existing v1alpha1 files in platform/ +
  products/ continue to validate; migration to v1 is a follow-up slice.
- Required at this layer: spec.version (strict semver pattern),
  spec.card.title (minLength=1).
- Card variants accommodated as documented: summary | description |
  tagline interchangeable; category | family interchangeable; docs |
  documentation interchangeable. All optional except title.
- visibility enum: listed | unlisted | private.
- placementSchema.modes enum: single-region | active-active | active-
  hotstandby — same set Application.spec.placement validates against.
- depends[].blueprint pattern accepts both bp-* and bare-name (legacy).
- manifests accepts both manifests.chart (legacy short-form) AND
  manifests.source.{kind,ref} (canonical). Three source kinds: HelmChart,
  Kustomize, OAM.
- rotation[].ttl pattern '^[0-9]+(s|m|h|d)$'.
- x-kubernetes-preserve-unknown-fields liberally on configSchema (per-
  Blueprint JSON Schema is arbitrary by design), card, manifests, owner,
  observability, outputs, depends[].values, manifests.values, etc.

Existing files validation:
- Surveyed all blueprint.yaml in platform/ + products/ (59 files).
- Card field frequency: title (59), summary (38), description (20+1),
  category (25), family (20), docs (20), documentation (14+1), icon (25),
  tags (14), license (14).
- 54 of 59 files passed the schema unchanged.
- 5 files used `depends: [- bp-name]` (string form) instead of the
  canonical `[- blueprint: bp-name]` object form per BLUEPRINT-AUTHORING
  §3. Those 5 files are fixed in this commit:
    * platform/cert-manager-powerdns-webhook/blueprint.yaml
    * platform/cert-manager-dynadot-webhook/blueprint.yaml
    * platform/crossplane-claims/blueprint.yaml
    * platform/powerdns/blueprint.yaml
    * platform/self-sovereign-cutover/blueprint.yaml
- After fix: ALL 59 files pass server-side validation (kubectl apply
  --dry-run=server) against the new CRD.

Negative validation (tests/blueprint-sample-invalid.yaml):
- spec.version "1.3" → semver pattern
- spec.card missing → required
- spec.card.title missing → required
- spec.visibility "secret" → enum listed|unlisted|private
- spec.placementSchema.modes "round-robin" → enum
- spec.depends[0] bare string "bp-bad-string" → must be object
- spec.depends[1].blueprint "Foo" → pattern fails (uppercase)
- spec.rotation[0].ttl "5 days" → pattern '^[0-9]+(s|m|h|d)$'
All 8 seeded vectors rejected.

This commit ONLY touches new CRD + test files + the 5 depends fixes —
leaves the in-flight router.tsx + rootBeforeLoad.test.ts work from a
parallel agent and the .claude/worktrees/ directory untouched.

Refs: #1094, #1095, docs/EPICS-1-6-unified-design.md §3.2.4,
docs/BLUEPRINT-AUTHORING.md §3

Co-authored-by: hatiyildiz <hatiyildiz@noreply.openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:25:08 +04:00
..
chart fix(bp-cert-manager-powerdns-webhook): re-target to contabo PowerDNS, drop dynadot-webhook (#681) 2026-05-03 17:12:48 +04:00
blueprint.yaml feat(catalyst-chart): land Blueprint CRD + fix 5 string-form depends (slice B4, #1095) (#1112) 2026-05-08 22:25:08 +04:00
README.md fix(bp-cert-manager-powerdns-webhook): re-target to contabo PowerDNS, drop dynadot-webhook (#681) 2026-05-03 17:12:48 +04:00

bp-cert-manager-powerdns-webhook

Catalyst Blueprint for the cert-manager DNS-01 external webhook for PowerDNS. Closes openova#373.

What it is

A wrapper around the upstream zachomedia/cert-manager-webhook-pdns binary that satisfies cert-manager's external webhook contract (webhook.acme.cert-manager.io/v1alpha1Present / CleanUp on a ChallengeRequest) and writes ACME challenge TXT records to the central openova PowerDNS (authoritative for omani.works) via PowerDNS's REST API at https://pdns.openova.io.

This blueprint supersedes bp-cert-manager-dynadot-webhook for omani.works Sovereigns: omani.works is registered at Dynadot but is delegated to ns1/2/3.openova.io which run on contabo's PowerDNS. Dynadot is NOT the API-level authority for omani.works subdomains; contabo PowerDNS is. Caught live on otech4346 where the dynadot webhook silently failed to write challenge TXT records visible on the public DNS chain.

How DNS-01 validation works for *.${SOVEREIGN_FQDN}

When Let's Encrypt validates a DNS-01 challenge for *.otechN.omani.works, its resolvers walk the public DNS chain: Dynadot → ns1/2/3.openova.io (contabo PowerDNS). Until pool-domain- manager has committed the per-Sovereign NS delegation into contabo PowerDNS — and that delegation has propagated — the Sovereign's own PowerDNS is INVISIBLE on the public chain.

This webhook writes the ACME challenge TXT record DIRECTLY to contabo's central PowerDNS, so Let's Encrypt validation succeeds on the first attempt regardless of whether the Sovereign-side delegation has sealed.

What this chart deploys

Resource Purpose
Deployment Runs the upstream zachomedia/cert-manager-webhook-pdns image as a non-root pod.
Service ClusterIP fronting the Deployment on port 443.
APIService Registers v1alpha1.acme.powerdns.openova.io so the kube-apiserver routes ChallengeRequest calls to the Service.
Issuer (selfsigned) Bootstraps the CA chain that issues the webhook's serving cert.
Issuer (CA) Signs the leaf serving cert from the CA Secret.
Certificate (CA) Root CA cert used by the APIService's cert-manager.io/inject-ca-from annotation.
Certificate (serving) Leaf cert mounted into the Deployment at /tls.
ServiceAccount Identity for the Deployment.
ClusterRoleBinding (auth-delegator) Lets the aggregated apiserver delegate auth back to kube-apiserver.
RoleBinding (auth-reader) Reads extension-apiserver-authentication ConfigMap from kube-system.
ClusterRole + ClusterRoleBinding (secret-reader) Grants the SA get on Secrets cluster-wide so it can read the PowerDNS API-key Secret on demand.
ClusterRole + ClusterRoleBinding (domain-solver) Lets cert-manager create ChallengeRequest CRs in the webhook's API group.
ClusterIssuer (letsencrypt-dns01-prod-powerdns) Paired DNS-01 issuer. Renders when clusterIssuer.enabled=true (chart's default powerdns.host=https://pdns.openova.io is sufficient for the omani.works pool; cluster overlays may override the host for non-omani.works pools).

Pairing with bp-cert-manager

The blueprint declares bp-cert-manager as depends: in blueprint.yaml (provides the cert-manager controllers + CRDs). It does NOT depend on bp-powerdns — the webhook calls contabo's central PowerDNS, an out-of-cluster endpoint, not the Sovereign's local PowerDNS.

Flux dependsOn enforces ordering at the HelmRelease level (see clusters/_template/bootstrap-kit/49-bp-cert-manager-powerdns-webhook.yaml).

Configuration (per-Sovereign overlay)

The chart's defaults render a runnable webhook + skip the ClusterIssuer (default clusterIssuer.enabled=false for safe CI smoke renders). Sovereign overlays flip clusterIssuer.enabled=true and set the email:

clusterIssuer:
  enabled: true
  email: ops@<sovereign-fqdn>
  acmeServer: https://acme-v02.api.letsencrypt.org/directory   # or staging during bring-up

# `powerdns.host` defaults to https://pdns.openova.io (contabo central
# PowerDNS, authoritative for omani.works). Override only when
# provisioning a Sovereign in a non-omani.works pool.
# powerdns:
#   host: "https://pdns.<other-pool>"

The credential Secret powerdns-api-credentials MUST live in the cert-manager namespace on every Sovereign (the upstream webhook ignores any namespace: field on the apiKeySecretRef and reads the Secret from cert-manager's cluster-resource-namespace). The Secret's api-key value MUST match the API key configured on contabo's central PowerDNS — provisioned by cloud-init at control-plane boot time (infra/hetzner/cloudinit-control-plane.tftpl).

Per docs/INVIOLABLE-PRINCIPLES.md #4 every URL/zone is operator- overridable. No hardcoded omantel.omani.works lives in this chart.

Smoke test

Once both charts (bp-cert-manager + bp-cert-manager-powerdns-webhook) are reconciled on a Sovereign:

# Verify the webhook is running and the APIService is healthy
kubectl get -n cert-manager deploy/release-name-bp-cert-manager-powerdns-webhook
kubectl get apiservices.apiregistration.k8s.io v1alpha1.acme.powerdns.openova.io

# Issue a wildcard cert against the Sovereign apex
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-omantel-omani-works
  namespace: kube-system
spec:
  secretName: wildcard-omantel-omani-works-tls
  issuerRef:
    name: letsencrypt-dns01-prod-powerdns
    kind: ClusterIssuer
  dnsNames:
    - "*.omantel.omani.works"
EOF

# Watch the Order + Challenge progress
kubectl get certificate,order,challenge -A -w

See also