- Go 78.7%
- Nix 10.2%
- Just 6.7%
- PLpgSQL 2.7%
- Go Template 1.7%
|
All checks were successful
CI / check (push) Successful in 19m9s
initdb refuses to run as root, and jobs on the nixos runner are root because nix needs to write a store that lives inside the container. That is why this job has never passed. ci: - The integration step goes through as-runner, which the runner image now provides. It drops to uid 1000 for that step alone, leaving every other step as root. |
||
|---|---|---|
| .forgejo/workflows | ||
| chart/external-dns-pgregistry | ||
| cmd/external-dns-pgregistry | ||
| docs | ||
| internal | ||
| nix | ||
| testdata | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
external-dns-pgregistry
A Postgres source and registry for external-dns, shipped as a drop-in binary.
Desired DNS state is read from a database instead of from Kubernetes objects, and record ownership is recorded in a table instead of in TXT records written into the customer's zone. Everything else — the reconcile loop, the planner, every provider, the whole flag set — is upstream external-dns, imported as a library.
your system ──writes──► Postgres ◄──reads── external-dns-pgregistry ──► DNS provider
▲ │
└────writes ownership────┘
Why
external-dns' default registry keeps ownership in the zone it manages: for every record, a companion TXT record saying "external-dns owns this". That works, and it has three costs that grow with the number of zones.
- Write traffic. Every record costs a second write against the provider's rate limit.
- The apex problem. For a record at a zone apex, the default naming puts the ownership TXT
outside the zone (
externaldns-a-example.comis a sibling ofexample.com), which any zone-scoped provider API rejects. See external-dns#5010. - Nothing else can read it. A system that wants to know what external-dns manages has to enumerate zones over the provider API and parse heritage strings back out.
Ownership in Postgres has none of those. The semantics are unchanged: external-dns still refuses to delete or modify a record it has no claim on.
The Kubernetes source has a separate problem at scale — one custom resource per domain, listed in
full on every reconcile interval — which a SELECT does not.
Upstream already accepts that the registry is pluggable: --registry=dynamodb stores ownership in
a DynamoDB table. This is that idea with a database you probably already run.
Installing
$ helm install edns oci://git.shine.town/infra/charts/external-dns-pgregistry \
--set txtOwnerId=my-cluster \
--set postgres.existingSecret=my-dsn \
--set provider.webhook.image.repository=example/my-provider
An initContainer applies the schema before the pod starts. migrations.schema defaults to
ownership, which creates dns_ownership and leaves the rest of the contract to the host. Set it
to reference to also create the example host tables and views, for a deployment that has no host
system yet:
--set migrations.schema=reference
The chart is written from scratch rather than derived from the upstream external-dns chart, but
value names follow it wherever they mean the same thing — policy, interval, txtOwnerId,
domainFilters, provider, serviceMonitor — so values carry over. postgres, shards and
shardIdFrom are the additions.
It renders two objects: a StatefulSet and a headless Service. No ServiceAccount, no Role, no
ClusterRole, and automountServiceAccountToken: false. That absence is why it warrants its own
chart: the upstream chart's entire RBAC exists to read Services and Ingresses, and the Postgres
source reads neither.
A StatefulSet rather than upstream's Deployment because each instance owns a distinct slice of the
zone table: two pods reconciling the same zone would race each other's writes. The pod ordinal is
the shard, so shards: 3 is three shards and kubectl scale adds one. Scaling down strands every
zone whose shard_id no longer has a pod, silently, because nothing is left looking at them.
shardIdFrom picks how a pod learns its ordinal:
podIndexLabel (default) |
read apps.kubernetes.io/pod-index through the downward API. Kubernetes sets it on StatefulSet pods; needs 1.28 with the PodIndexLabel gate, or 1.32 where it is unconditional |
hostname |
parse the ordinal off the pod hostname, via --shard-id-from-hostname. Works on any version |
Running it directly
$ external-dns-pgregistry \
--source=postgres \
--registry=postgres \
--postgres-dsn="postgres://user:pass@host/db" \
--shard-id=0 \
--txt-owner-id=my-cluster \
--provider=webhook \
--policy=sync
Every other upstream flag works unchanged.
| Flag | |
|---|---|
--postgres-dsn |
connection string; required in Postgres mode. Also EXTERNAL_DNS_POSTGRES_DSN, which is how the chart passes it, because an argument is visible in kubectl describe pod |
--shard-id |
which slice of the zone table this instance owns; default 0. Also EXTERNAL_DNS_SHARD_ID |
--shard-id-from-hostname |
take the shard from a StatefulSet pod's ordinal. The image contains a binary and a CA bundle and no shell, so a chart cannot do this with ${HOSTNAME##*-} |
--check-config |
validate and exit, without connecting to anything |
--txt-owner-id |
the owner claims are recorded under. Named for the TXT registry, though no TXT record is written |
--source and --registry are closed enums upstream, so postgres is rewritten to the neutral
upstream values before their parser sees it. This is logged at startup.
No cluster access. In Postgres mode the Kubernetes sources are never constructed: no
kubeconfig, no ServiceAccount, no RBAC, no informers. It talks to Postgres and to whatever
--provider selects, and nothing else.
The contract
Three relations and one notification channel, specified in
testdata/contract.sql:
dns_zones |
a view: the zones this shard is authoritative for |
dns_desired_endpoints |
a view: the records that ought to exist, one row per RRset |
dns_ownership |
a table: the claims. The only object this binary writes. |
NOTIFY dns_intent_changed |
tells the source to reconcile now instead of at the next --interval |
The host owns the two reads. They are views, so it can restructure freely underneath as long as the
column lists hold — testdata/fixture.sql is one implementation, used by
the tests.
dns_ownership is the one object this binary can own, and --migrate --migrate-schema=ownership
creates it. A host that would rather define it itself sets migrations.enabled: false; note then
that this schema declares zone_id without a foreign key, because contract.sql names a zones
table only the reference implementation has. Both definitions are create table if not exists, so
if the host and the initContainer disagree, whichever runs first wins and nothing says so.
Because the domain filter is a query rather than a command-line flag, onboarding a zone needs no restart — the one behaviour here that upstream's registries cannot offer.
Ownership, precisely
A claim exists if and only if this system created the record. Absence is meaningful: it is what protects a customer's pre-existing MX, SPF and DKIM records.
ApplyChanges claims before writing and releases after, which is upstream's ordering and is
deliberate. A crash between the two leaves a claim with no record — detectable, and released as an
orphan on the next pass — rather than a record with no claim, which is indistinguishable from a
customer's own and could never be cleaned up.
Losing a race for a claim abandons the create rather than overwriting the other owner.
Development
$ nix develop # or `direnv allow`
$ just ci # fmt, vet, staticcheck, tidy, unit tests, build, chart checks
$ just test-integration # starts a throwaway Postgres via initdb
$ just image # OCI image, streamed to stdout
$ just chart-args-check # feed the chart's rendered args back to the binary
chart-args-check exists because the chart and the binary are separate artifacts with
no compiler between them. Without it a renamed flag ships and is discovered as a
CrashLoopBackOff.
The integration tests run initdb into a temporary directory and listen on a Unix socket only, so
they need no daemon, no network and no container, and two test binaries cannot collide.
docs/vendoring.md records which upstream packages this depends on, at which
version, and which upstream behaviours the implementation relies on. Read it before a version bump:
none of these packages carries an API stability promise, and a bump is expected to break the build.
Releases
.forgejo/workflows/release.yaml builds the image with Nix and pushes it with skopeo. A push to
main publishes :latest and :<short-sha>; a v* tag additionally publishes the version, so a
chart can pin something that does not move underneath it.
The chart goes to oci://git.shine.town/infra/charts as an OCI artifact, deliberately not
alongside the image. helm push appends the chart name, which here is also the image name, so both
would write into infra/external-dns-pgregistry and a v0.2.0 tag would put the image over the
chart.
Both need a registry token in the PGREGISTRY_PACKAGE_UPLOAD_KEY secret.
Status
Early. The ownership invariants are covered by integration tests against a real Postgres and a fake provider, including a check that an unclaimed record survives a planner that asks for its deletion. It has not yet run against a production zone.
License
MIT or Apache-2.0, at your option.