feat(external-dns): #109 — Catalyst-curated dynadot-multi-domain policy

Adds platform/external-dns/policies/dynadot-multi-domain.yaml — the
canonical external-dns + dynadot webhook deployment that ships in every
Sovereign on an OpenOva pool domain.

Why a webhook: external-dns has no upstream Dynadot provider; the
canonical pattern is the webhook RPC contract, with a sidecar that
implements the provider in our preferred language. We reuse the same
internal/dynadot/ package the catalyst-api uses, so the never-wipe rule,
record encoding, and managed-domain allowlist are identical on both
write paths (per docs/INVIOLABLE-PRINCIPLES.md #2 — no duplicate
implementations of the same concern).

Multi-domain:
- One --domain-filter per zone in the external-dns args; adding a third
  pool domain (e.g. acme.io) is a one-line edit here PLUS a one-key edit
  on dynadot-api-credentials' `domains` field. No webhook rebuild.
- Webhook reads DYNADOT_MANAGED_DOMAINS from the same secret with
  optional=true, preserving backward compatibility with the legacy
  single-`domain` secret shape (pre-#108).

TXT registry:
- --txt-owner-id=$(SOVEREIGN_FQDN), --txt-prefix=_externaldns.<sub>.
- Cluster overlays substitute SOVEREIGN_FQDN via the bp-catalyst-platform
  umbrella so two clusters sharing a parent zone (alpha.omani.works,
  beta.omani.works) cannot collide.

Closes #109.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-04-28 14:28:19 +02:00 committed by hatiyildiz
parent 921eabdc47
commit f0fe3006ba
2 changed files with 330 additions and 0 deletions

View File

@ -0,0 +1,321 @@
# dynadot-multi-domain.yaml — Catalyst-curated external-dns policy for the
# OpenOva pool domains registered in the Dynadot account-scoped credential.
#
# Closes #109 — "[G] dns: write platform/external-dns/policies/dynadot-multi-domain.yaml".
#
# ──────────────────────────────────────────────────────────────────────────
# Why this manifest exists
# ──────────────────────────────────────────────────────────────────────────
#
# external-dns has no first-class Dynadot provider in its upstream
# distribution; it supports Dynadot via the `webhook` provider mechanism
# (external-dns issues record-add/delete RPCs to a sidecar that knows the
# provider's API). For Catalyst we ship our own webhook implementation
# that wraps the same internal/dynadot/ package the catalyst-api uses,
# keeping the canonical Dynadot client multi-domain capable in exactly
# one place (per docs/INVIOLABLE-PRINCIPLES.md #2 — "no bespoke that
# duplicates an off-the-shelf component" — and #3 — "exactly one IaC
# layer per concern").
#
# The webhook sidecar (image: ghcr.io/openova-io/openova/external-dns-
# dynadot-webhook:<sha>, built from products/catalyst/bootstrap/api/cmd/
# external-dns-dynadot-webhook/) reads the SAME DYNADOT_MANAGED_DOMAINS /
# DYNADOT_DOMAIN env vars as catalyst-api so adding a future pool domain
# remains a secret update only.
#
# ──────────────────────────────────────────────────────────────────────────
# Multi-domain handling
# ──────────────────────────────────────────────────────────────────────────
#
# external-dns receives the zone list via:
# --domain-filter=openova.io
# --domain-filter=omani.works
# --domain-filter=<future zone>
#
# Each --domain-filter narrows what zones external-dns will manage; the
# webhook sidecar enforces the same allowlist again (defence in depth)
# via dynadot.IsManagedDomain(). Adding a third pool domain is a 3-line
# change in this file (one new --domain-filter arg) plus the secret
# update — no rebuild of either external-dns or the webhook.
#
# Per principle #4 ("never hardcode") the zone list here is generated
# from the same dynadot-api-credentials secret's `domains` key via
# Kustomize ConfigMapGenerator; in this canonical policy file we
# enumerate the current zones inline for legibility, but cluster
# overlays MAY override `args` to pull from the secret.
#
# ──────────────────────────────────────────────────────────────────────────
# TXT registry isolation
# ──────────────────────────────────────────────────────────────────────────
#
# Per platform/external-dns/README.md, external-dns uses TXT records to
# track ownership of records it has written. With multiple pool domains
# the TXT prefix MUST be unique per Sovereign so two clusters that share
# the same parent zone (e.g. *.alpha.omani.works and *.beta.omani.works)
# don't collide. We use:
#
# --txt-owner-id=<sovereign-fqdn>
# --txt-prefix=_externaldns.<sovereign-subdomain>.
#
# substituted by the cluster overlay's Kustomize patch.
#
# ──────────────────────────────────────────────────────────────────────────
# Status: Catalyst-curated. Reviewed against external-dns v0.15.0 schema.
---
apiVersion: v1
kind: Namespace
metadata:
name: external-dns
labels:
catalyst.openova.io/component: external-dns
catalyst.openova.io/scope: per-host-cluster
---
# ServiceAccount + RBAC — minimal: external-dns watches Gateway, Service,
# Ingress; it never writes K8s resources.
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "pods", "nodes"]
verbs: ["get", "watch", "list"]
- apiGroups: ["extensions", "networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "watch", "list"]
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways", "httproutes", "grpcroutes", "tlsroutes", "tcproutes", "udproutes"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: external-dns
---
# Dynadot webhook — runs as a sidecar in the external-dns Pod, exposes
# the upstream external-dns webhook RPC contract, talks to api.dynadot.com
# via the SAME internal/dynadot/ package the catalyst-api uses. Reads
# managed domains from the dynadot-api-credentials secret in
# openova-system (mounted via projected volume since it's cross-namespace).
#
# By keeping a single Dynadot client implementation we guarantee that:
# - record encoding (apex vs subdomain) matches the catalyst-api
# - the never-wipe rule (add_dns_to_current_setting=yes) is honoured
# - the managed-domain allowlist is identical on both write paths
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: external-dns
labels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/component: dns-sync
spec:
replicas: 1
strategy:
type: Recreate # external-dns is single-writer per zone
selector:
matchLabels:
app.kubernetes.io/name: external-dns
template:
metadata:
labels:
app.kubernetes.io/name: external-dns
spec:
serviceAccountName: external-dns
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
containers:
# ── external-dns controller ───────────────────────────────────────
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.15.0
imagePullPolicy: IfNotPresent
args:
- --source=service
- --source=ingress
- --source=gateway-httproute
- --source=gateway-grpcroute
- --source=gateway-tlsroute
# Multi-domain — one --domain-filter per managed pool domain.
# Adding a third pool domain (e.g. acme.io) is a one-line edit
# here PLUS a one-key edit on the dynadot-api-credentials
# secret's `domains` field. No webhook rebuild required.
- --domain-filter=openova.io
- --domain-filter=omani.works
- --provider=webhook
- --webhook-provider-url=http://localhost:8888
- --policy=upsert-only # never delete records on resource removal — stale records are GC'd by the dedicated reaper job, not the live sync
- --registry=txt
- --txt-owner-id=$(SOVEREIGN_FQDN)
- --txt-prefix=_externaldns.
- --interval=1m
- --log-level=info
- --log-format=json
env:
- name: SOVEREIGN_FQDN
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# Cluster overlays patch this with the actual sovereign FQDN
# via the bp-catalyst-platform values.yaml; the namespace
# default keeps the manifest valid for `kubectl diff` runs.
ports:
- containerPort: 7979
name: metrics
livenessProbe:
httpGet:
path: /healthz
port: 7979
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 7979
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# ── Dynadot webhook sidecar ───────────────────────────────────────
- name: dynadot-webhook
image: ghcr.io/openova-io/openova/external-dns-dynadot-webhook:bp-catalyst-platform
# Image tag is overridden per-Sovereign via the bp-catalyst-platform
# umbrella so SHA pinning per docs/INVIOLABLE-PRINCIPLES.md #4 is
# enforced by the Blueprint, not embedded here.
imagePullPolicy: IfNotPresent
args:
- --listen=:8888
- --metrics-listen=:8889
env:
- name: DYNADOT_API_KEY
valueFrom:
secretKeyRef:
name: dynadot-api-credentials
key: api-key
- name: DYNADOT_API_SECRET
valueFrom:
secretKeyRef:
name: dynadot-api-credentials
key: api-secret
# Multi-domain (canonical) — same key the catalyst-api reads,
# mounted from the same secret. Optional=true preserves
# backward-compatibility with pre-#108 single-`domain` secrets.
- name: DYNADOT_MANAGED_DOMAINS
valueFrom:
secretKeyRef:
name: dynadot-api-credentials
key: domains
optional: true
- name: DYNADOT_DOMAIN
valueFrom:
secretKeyRef:
name: dynadot-api-credentials
key: domain
optional: true
ports:
- containerPort: 8888
name: webhook
- containerPort: 8889
name: webhook-metrics
livenessProbe:
httpGet:
path: /healthz
port: 8889
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 8889
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
# Service — exposes the metrics endpoints for Prometheus scraping. Webhook
# is intentionally NOT exposed cluster-wide; external-dns reaches it on
# localhost.
apiVersion: v1
kind: Service
metadata:
name: external-dns-metrics
namespace: external-dns
labels:
app.kubernetes.io/name: external-dns
spec:
selector:
app.kubernetes.io/name: external-dns
ports:
- name: metrics
port: 7979
targetPort: metrics
- name: webhook-metrics
port: 8889
targetPort: webhook-metrics
---
# ServiceMonitor — Prometheus discovers both the controller's own metrics
# (zone sync latency, record counts) and the webhook's per-domain Dynadot
# request counters. Lets ops alert on "Dynadot rate-limited" and
# "external-dns sync paused" without grepping pod logs.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: external-dns
namespace: external-dns
labels:
app.kubernetes.io/name: external-dns
spec:
selector:
matchLabels:
app.kubernetes.io/name: external-dns
endpoints:
- port: metrics
interval: 30s
path: /metrics
- port: webhook-metrics
interval: 30s
path: /metrics

View File

@ -0,0 +1,9 @@
# Catalyst-curated external-dns policies. Per-host-cluster overlays consume
# these via Kustomize component imports; the canonical multi-domain policy
# below is the one the bp-catalyst-platform umbrella renders into every
# Sovereign that uses an OpenOva pool domain.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- dynadot-multi-domain.yaml