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:
parent
921eabdc47
commit
f0fe3006ba
321
platform/external-dns/policies/dynadot-multi-domain.yaml
Normal file
321
platform/external-dns/policies/dynadot-multi-domain.yaml
Normal 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
|
||||
9
platform/external-dns/policies/kustomization.yaml
Normal file
9
platform/external-dns/policies/kustomization.yaml
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user