openova/platform/cert-manager-dynadot-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-dynadot-webhook): pin SHA + add ghcr-pull imagePullSecret (#643) 2026-05-02 23:52:42 +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 feat(dns): cert-manager-dynadot-webhook for DNS-01 wildcard TLS (closes #159) (#291) 2026-04-30 19:37:47 +04:00

bp-cert-manager-dynadot-webhook

Catalyst Blueprint for the cert-manager DNS-01 external webhook for Dynadot. Closes openova#159.

What it is

A Go 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 a Dynadot-managed pool domain via the api3.json endpoint.

The binary lives at core/cmd/cert-manager-dynadot-webhook/. The HTTP transport, command builders, and zone-safety contract live in core/pkg/dynadot-client/ and are shared with the other Catalyst services that talk to Dynadot (pool-domain-manager, catalyst-dns).

Why this exists separately from external-dns-dynadot-webhook

cert-manager's webhook contract and external-dns's webhook contract are DIFFERENT protocols. external-dns expects a sidecar that implements records.list / records.add / records.delete over an HTTP RPC schema; cert-manager expects an aggregated Kubernetes apiserver that responds to ChallengeRequest CRs. The two binaries cannot share code at the transport layer. They DO share the underlying Dynadot HTTP client at core/pkg/dynadot-client/.

What this chart deploys

Resource Purpose
Deployment Runs the webhook binary as a non-root pod in the chart's release namespace.
Service ClusterIP fronting the Deployment on port 443.
APIService Registers v1alpha1.acme.dynadot.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.
Role + RoleBinding (dynadot secret) Grants the SA read access to the Dynadot credentials Secret in the configured namespace.

Pairing with bp-cert-manager

bp-cert-manager's letsencrypt-dns01-prod ClusterIssuer points at this webhook via solvers[].dns01.webhook.groupName + solverName. The two charts MUST be deployed on the same Sovereign and bp-cert-manager-dynadot- webhook MUST be Ready before any wildcard Certificate is requested.

The bp-cert-manager chart now ships with dns01.enabled: true by default (changed in this PR — was false while the webhook was being built). The interim letsencrypt-http01-prod issuer remains templated as the rollback path; flip certManager.issuers.dns01.enabled=false in the umbrella values to disable wildcard issuance and continue with per-host certs.

Credentials

The webhook reads three values from a Kubernetes Secret in its release namespace:

Env var Default secret key
DYNADOT_API_KEY api-key
DYNADOT_API_SECRET api-secret
DYNADOT_MANAGED_DOMAINS domains (legacy fallback: domain)

The canonical secret (dynadot-api-credentials in openova-system) is shared with pool-domain-manager and catalyst-dns. Because Pod secretKeyRef cannot cross namespaces, the cluster overlay MUST replicate the secret into the webhook's release namespace via ExternalSecret (preferred) or reflector annotations. See clusters/_template/dynadot-credentials-replication.yaml.

Domain allowlist

DYNADOT_MANAGED_DOMAINS is a comma- or whitespace-separated allowlist of pool domains the webhook is permitted to mutate. ChallengeRequests for domains NOT under any allowlisted apex are rejected before any Dynadot API call is made. This is the same defence pattern pool-domain-manager and catalyst-dns use; it prevents a misconfigured ClusterIssuer from causing the webhook to write to a third-party domain.

Zone safety

The shared core/pkg/dynadot-client/ enforces the safety contract documented in memory/feedback_dynadot_dns.md: every mutation either uses the append path (add_dns_to_current_setting=yes) or performs a read-modify-write via domain_info → set_dns2. The destructive zone-wipe variant of set_dns2 is unexported. The webhook's Present path uses AddRecord (append); CleanUp uses RemoveSubRecord (read-modify-write that match-deletes a single record).

Smoke test

Once both charts 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-dynadot-webhook
kubectl get apiservices.apiregistration.k8s.io v1alpha1.acme.dynadot.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: cilium-gateway
spec:
  secretName: wildcard-omantel-omani-works-tls
  issuerRef:
    name: letsencrypt-dns01-prod
    kind: ClusterIssuer
  dnsNames:
    - "*.omantel.omani.works"
EOF

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

See also

  • core/cmd/cert-manager-dynadot-webhook/ — binary source
  • core/pkg/dynadot-client/ — shared Dynadot HTTP client
  • platform/cert-manager/chart/templates/clusterissuer-letsencrypt-dns01.yaml — paired ClusterIssuer
  • openova#159 — closing issue
  • cert-manager DNS-01 webhook docs