20 Commits

Author SHA1 Message Date
benvin 057575b9b5 Merge pull request 'Reconcile zone apex NS off the pod IP placeholder' (#22) from benvin/zone-nameservers into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #22
2026-09-26 23:48:59 +10:00
unkin-agent cc714dc2b4 apply records before the apex NS, so in-zone glue exists first
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
named rejects an apex NS pointing at an in-zone name with no address record,
so the glue has to land in an earlier transaction.
2026-09-26 19:41:53 +10:00
unkin-agent 08d46ccce0 read the live apex NS with a signed query, retract nothing when unreadable
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
An unsigned localhost query is REFUSED for a zone behind a key-matched view and
answers empty with exit 0; signing it with the update creds reaches the view.
2026-09-26 19:25:06 +10:00
unkin-agent dc57ac1b2d retract apex NS from recorded state, not a live query
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
An unsigned localhost query silently returns nothing for a zone behind a
BindView, which would strand the placeholder. Record what was published instead.
2026-09-26 19:06:47 +10:00
unkin-agent e4ed6c8052 converge apex NS per record, not by RRset replace
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
BIND ignores an RRset-wide delete of apex NS, so the previous replace only
appended to the seed placeholder.
2026-09-26 18:50:55 +10:00
unkin-agent 4a41cbc427 reconcile zone apex NS off the pod IP placeholder
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add BindZone.spec.nameservers and sync the apex NS RRset on every reconcile.
2026-09-26 18:27:11 +10:00
benvin 03df5d56b4 Merge pull request 'Inspect zone file and journal before seeding a zone' (#20) from benvin/zone-seed-journal-safe into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #20
2026-09-19 23:42:03 +10:00
unkin-agent d1dd5040d5 Abort the seed and probe scripts on the first failed command
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Guard the staged size as a string so an unmeasurable file fails closed.
2026-09-19 23:31:09 +10:00
unkin-agent 949662a334 Read quarantine evidence back when planning a seed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-19 23:14:26 +10:00
unkin-agent 6730b8bcb2 Stage and install the seed zone file in one exec
Quarantine and write were separate round-trips, so a failure between them
left the PVC with no zone data and the next reconcile reseeded at serial 1.
The write also truncated the destination in place, leaving a torn file that
PlanSeed refuses to touch.
2026-09-19 23:14:26 +10:00
unkin-agent 0065268372 Unexport the unguarded seed write
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-09-19 22:56:49 +10:00
unkin-agent 6b5e465093 Restrict zone names to DNS label characters
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
ci/woodpecker/pr/build Pipeline was canceled
2026-09-19 22:55:27 +10:00
unkin-agent da679285f0 Route every zone seed through a fail-closed journal check 2026-09-19 22:55:27 +10:00
unkin-agent f1d47c8ff7 Inspect zone file and journal before seeding a zone
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Fixes #19
2026-09-19 22:33:48 +10:00
benvin 8d5a231a78 Merge pull request 'ci: add buildkit_config CA trust for artifactapi push' (#18) from benvin/buildx-ca-config into main
Reviewed-on: #18
2026-08-15 18:47:21 +10:00
unkin-agent f2b7d07006 ci: add buildkit_config CA trust for artifactapi push
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Point the buildx docker-container builder at artifactapi's in-image CA
(/etc/docker/certs.d/.../ca.crt) so buildkitd, which runs in its own
container and performs the push, trusts the registry. Applied to both
push steps (docker-operator and docker-tsig-api). Proven in jellyfin-ha
v0.1.2.
2026-08-15 18:30:44 +10:00
benvin 7e736248e4 Merge pull request 'ci: use CA-baked plugin-docker-buildx image for artifactapi push' (#17) from benvin/buildx-ca-plugin-image into main
Reviewed-on: #17
2026-08-15 18:20:59 +10:00
unkin-agent 698ca8c102 ci: use CA-baked plugin-docker-buildx image for artifactapi push
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-08-15 18:03:44 +10:00
benvin 3a88fd95a0 Merge pull request 'ci: push images to artifactapi registry instead of gitea' (#16) from benvin/push-artifactapi into main
Reviewed-on: #16
2026-07-30 20:55:27 +10:00
unkinben eb65ad8f89 ci: push images to artifactapi registry instead of gitea
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Hard switch of the docker push target from the Gitea registry to the
artifactapi local docker registry (docker-internal); the Gitea VM and its
registry are being retired. Drops the droneci/DRONECI_PASSWORD creds since
artifactapi accepts unauthenticated in-cluster pushes. Also updates the README push note (covers bind-operator + bind-tsig-api).

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-30 00:34:59 +10:00
27 changed files with 2022 additions and 72 deletions
+4 -4
View File
@@ -3,15 +3,15 @@ when:
steps:
- name: docker-build-operator
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
repo: git.unkin.net/unkin/bind-operator
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
dockerfile: Dockerfile.operator
dry_run: true
- name: docker-build-tsig-api
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
repo: git.unkin.net/unkin/bind-tsig-api
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
dockerfile: Dockerfile.tsigapi
dry_run: true
+12 -12
View File
@@ -4,27 +4,27 @@ when:
steps:
- name: docker-operator
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/bind-operator
registry: artifactapi.k8s.syd1.au.unkin.net
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
dockerfile: Dockerfile.operator
username: droneci
password:
from_secret: DRONECI_PASSWORD
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
tags:
- ${CI_COMMIT_TAG}
- latest
- name: docker-tsig-api
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/bind-tsig-api
registry: artifactapi.k8s.syd1.au.unkin.net
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
dockerfile: Dockerfile.tsigapi
username: droneci
password:
from_secret: DRONECI_PASSWORD
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
tags:
- ${CI_COMMIT_TAG}
- latest
+2 -1
View File
@@ -85,7 +85,8 @@ kubectl apply -f config/samples/
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
on pull requests; pushing a `v*` tag builds and pushes
`git.unkin.net/unkin/bind-operator` to the Gitea registry.
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator` (and
`bind-tsig-api`) to the artifactapi local docker registry.
## Notes & caveats
+5 -1
View File
@@ -11,7 +11,11 @@ type BindCatalogZoneSpec struct {
// ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"`
// ZoneName is the catalog zone's own origin, e.g. "catalog.internal".
// ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
// interpolated into shell commands run in the BIND pod, so it is restricted
// to DNS label characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"`
// DefaultPrimaries are the addresses member zones point at on secondaries.
+3 -1
View File
@@ -58,7 +58,9 @@ type BindClusterSpec struct {
// +optional
Replicas int32 `json:"replicas,omitempty"`
// Image is the BIND9 container image. Must ship named, rndc and nsupdate.
// Image is the BIND9 container image. Must ship named, rndc, nsupdate and
// the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
// tr, wc, rm, mv.
// +kubebuilder:default="internetsystemsconsortium/bind9:9.20"
// +optional
Image string `json:"image,omitempty"`
+5 -1
View File
@@ -42,7 +42,11 @@ type BindPolicySpec struct {
// +optional
ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the RPZ zone origin, e.g. "rpz.internal".
// ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
// into shell commands run in the BIND pod, so it is restricted to DNS label
// characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"`
// Order controls this policy's position in the response-policy clause.
+15
View File
@@ -48,6 +48,10 @@ type BindZoneSpec struct {
ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
// It is interpolated into shell commands run in the BIND pod, so it is
// restricted to DNS label characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"`
// Type is the zone type. Defaults to primary.
@@ -60,6 +64,17 @@ type BindZoneSpec struct {
// +optional
DefaultTTL int32 `json:"defaultTTL,omitempty"`
// Nameservers are the names published in the zone's apex NS RRset, kept in
// sync on every reconcile. Each entry is a full domain name, never relative
// to the zone. Prefer out-of-zone names glued by the parent: an
// in-zone name needs an address record in the zone, and the seed can only
// supply the primary pod's (unstable) IP for it. When empty the operator
// leaves the apex NS alone and a newly seeded zone gets the primary's stable
// in-cluster DNS name; clearing the field later does not retract what it
// published.
// +optional
Nameservers []string `json:"nameservers,omitempty"`
// Records are static record sets seeded into a primary zone.
// +optional
Records []Record `json:"records,omitempty"`
+5
View File
@@ -970,6 +970,11 @@ func (in *BindZoneList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) {
*out = *in
if in.Nameservers != nil {
in, out := &in.Nameservers, &out.Nameservers
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Records != nil {
in, out := &in.Records, &out.Records
*out = make([]Record, len(*in))
@@ -73,7 +73,12 @@ spec:
transfers to secondaries.
type: string
zoneName:
description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal".
description: |-
ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
interpolated into shell commands run in the BIND pod, so it is restricted
to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
@@ -995,8 +995,10 @@ spec:
type: array
image:
default: internetsystemsconsortium/bind9:9.20
description: Image is the BIND9 container image. Must ship named,
rndc and nsupdate.
description: |-
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
tr, wc, rm, mv.
type: string
imagePullPolicy:
description: ImagePullPolicy for the BIND container.
@@ -118,7 +118,12 @@ spec:
description: ViewRef optionally scopes the policy to a single view.
type: string
zoneName:
description: ZoneName is the RPZ zone origin, e.g. "rpz.internal".
description: |-
ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
into shell commands run in the BIND pod, so it is restricted to DNS label
characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
+19 -1
View File
@@ -94,6 +94,19 @@ spec:
items:
type: string
type: array
nameservers:
description: |-
Nameservers are the names published in the zone's apex NS RRset, kept in
sync on every reconcile. Each entry is a full domain name, never relative
to the zone. Prefer out-of-zone names glued by the parent: an
in-zone name needs an address record in the zone, and the seed can only
supply the primary pod's (unstable) IP for it. When empty the operator
leaves the apex NS alone and a newly seeded zone gets the primary's stable
in-cluster DNS name; clearing the field later does not retract what it
published.
items:
type: string
type: array
primaries:
description: Primaries lists source servers for a secondary/stub-type
zone.
@@ -159,7 +172,12 @@ spec:
description: ViewRef optionally binds this zone to a BindView.
type: string
zoneName:
description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
description: |-
ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
It is interpolated into shell commands run in the BIND pod, so it is
restricted to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
+35 -5
View File
@@ -219,7 +219,12 @@ spec:
transfers to secondaries.
type: string
zoneName:
description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal".
description: |-
ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
interpolated into shell commands run in the BIND pod, so it is restricted
to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
@@ -1300,8 +1305,10 @@ spec:
type: array
image:
default: internetsystemsconsortium/bind9:9.20
description: Image is the BIND9 container image. Must ship named,
rndc and nsupdate.
description: |-
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
tr, wc, rm, mv.
type: string
imagePullPolicy:
description: ImagePullPolicy for the BIND container.
@@ -1937,7 +1944,12 @@ spec:
description: ViewRef optionally scopes the policy to a single view.
type: string
zoneName:
description: ZoneName is the RPZ zone origin, e.g. "rpz.internal".
description: |-
ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
into shell commands run in the BIND pod, so it is restricted to DNS label
characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
@@ -2748,6 +2760,19 @@ spec:
items:
type: string
type: array
nameservers:
description: |-
Nameservers are the names published in the zone's apex NS RRset, kept in
sync on every reconcile. Each entry is a full domain name, never relative
to the zone. Prefer out-of-zone names glued by the parent: an
in-zone name needs an address record in the zone, and the seed can only
supply the primary pod's (unstable) IP for it. When empty the operator
leaves the apex NS alone and a newly seeded zone gets the primary's stable
in-cluster DNS name; clearing the field later does not retract what it
published.
items:
type: string
type: array
primaries:
description: Primaries lists source servers for a secondary/stub-type
zone.
@@ -2813,7 +2838,12 @@ spec:
description: ViewRef optionally binds this zone to a BindView.
type: string
zoneName:
description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
description: |-
ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
It is interpolated into shell commands run in the BIND pod, so it is
restricted to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string
required:
- clusterRef
+4 -3
View File
@@ -71,10 +71,11 @@ spec:
- key transfer-key
updateKeyRef: transfer-key
dynamicUpdate: true
# Published apex NS, kept in sync on every reconcile. Full names only; an
# in-zone name (as here) needs its address record below.
nameservers:
- ns1.internal.example.com.
records:
- name: "@"
type: NS
values: ["ns1.internal.example.com."]
- name: ns1
type: A
values: ["10.0.0.53"]
+1
View File
@@ -23,6 +23,7 @@ const (
NamedBin = "/usr/sbin/named"
RndcBin = "/usr/sbin/rndc"
NsupdateBin = "/usr/bin/nsupdate"
DigBin = "/usr/bin/dig"
)
// Config file paths derived from ConfigDir.
+69 -17
View File
@@ -19,35 +19,87 @@ type RecordUpdate struct {
Type string // RR type
TTL int32 // record TTL
Values []string // RDATA entries
Delete bool // when true, delete the RRset instead of replacing it
Delete bool // when true, delete instead of add
// PerValue operates on individual records rather than the whole RRset: adds
// leave existing records in place, deletes remove only the listed Values.
// Required at a zone apex, where BIND silently ignores an RRset-wide delete
// of NS or SOA and would turn a replace into an append.
PerValue bool
}
// NSUpdate applies a set of record changes to zone by executing nsupdate on the
// primary pod, targeting the local server and authenticating with creds. All
// changes are sent in a single atomic transaction.
func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
b.WriteString(fmt.Sprintf("zone %s\n", dot(zone)))
for _, u := range updates {
// Replace semantics: clear the RRset first, then add the desired values.
b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type))
if u.Delete {
continue
}
for _, v := range u.Values {
b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v))
}
}
b.WriteString("send\n")
cmd := []string{NsupdateBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)}
if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil {
if out, err := e.Exec(ctx, namespace, pod, cmd, nsupdateScript(zone, updates)); err != nil {
return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// ApexNS returns the zone's currently published apex NS names, so the operator
// can converge the RRset rather than append to it. The query is TSIG-signed with
// the same creds as an update: a zone behind a view whose match-clients is a key
// is unreachable to an unsigned query, which named answers REFUSED (with an empty
// body and a zero exit status), and the caller must not read that as "no NS".
func (e *Executor) ApexNS(ctx context.Context, namespace, pod, zone string, creds TSIGCreds) ([]string, error) {
cmd := []string{
DigBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret),
"+short", "+time=5", "+tries=1", "@127.0.0.1", dot(zone), "NS",
}
out, err := e.Exec(ctx, namespace, pod, cmd, "")
if err != nil {
return nil, fmt.Errorf("query apex NS of %s: %w (out: %s)", zone, err, out)
}
return parseDigNames(out), nil
}
// parseDigNames picks the answers out of `dig +short` output: one fully-qualified
// name per line. Anything without a trailing dot is not a name, and dig prefixes
// its diagnostics (a missing or mismatched TSIG key among them) with ';'.
func parseDigNames(out string) []string {
var names []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, ";") || !strings.HasSuffix(line, ".") {
continue
}
names = append(names, line)
}
return names
}
// nsupdateScript renders the nsupdate input for a set of changes.
func nsupdateScript(zone string, updates []RecordUpdate) string {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
fmt.Fprintf(&b, "zone %s\n", dot(zone))
for _, u := range updates {
switch {
case u.PerValue && u.Delete:
for _, v := range u.Values {
fmt.Fprintf(&b, "update delete %s %s %s\n", dot(u.FQDN), u.Type, v)
}
case u.PerValue:
for _, v := range u.Values {
fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)
}
default:
// Replace semantics: clear the RRset first, then add the values.
fmt.Fprintf(&b, "update delete %s %s\n", dot(u.FQDN), u.Type)
if u.Delete {
continue
}
for _, v := range u.Values {
fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)
}
}
}
b.WriteString("send\n")
return b.String()
}
// dot ensures a name is fully qualified with a trailing dot.
func dot(name string) string {
if name == "" || name == "@" {
+64
View File
@@ -0,0 +1,64 @@
package bind
import (
"strings"
"testing"
)
func TestNSUpdateScriptReplaceSemantics(t *testing.T) {
got := nsupdateScript("acme.unkin.net", []RecordUpdate{
{FQDN: "www", Type: "A", TTL: 60, Values: []string{"10.0.0.1", "10.0.0.2"}},
{FQDN: "old.acme.unkin.net.", Type: "TXT", Delete: true},
})
want := `server 127.0.0.1
zone acme.unkin.net.
update delete www. A
update add www. 60 A 10.0.0.1
update add www. 60 A 10.0.0.2
update delete old.acme.unkin.net. TXT
send
`
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}
// At the apex BIND ignores an RRset-wide delete of NS, so the apex sync must add
// the new names and delete the old ones record by record, adds first: named
// refuses to leave an apex with no NS record.
func TestNSUpdateScriptPerValueApexNS(t *testing.T) {
got := nsupdateScript("acme.unkin.net", []RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"acme-ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
})
want := `server 127.0.0.1
zone acme.unkin.net.
update add acme.unkin.net. 60 NS acme-ns1.unkin.net.
update delete acme.unkin.net. NS ns1.acme.unkin.net.
update delete ns1.acme.unkin.net. A
send
`
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestParseDigNames(t *testing.T) {
cases := []struct {
name, out, want string
}{
{"answers", "a.ns.unkin.net.\nb.ns.unkin.net.\n", "a.ns.unkin.net.,b.ns.unkin.net."},
{"REFUSED, SERVFAIL and NXDOMAIN all answer empty", "", ""},
// A zone behind a key-matched view answers an unsigned query REFUSED, and
// dig reports the key problem on a ';' line that happens to end in a dot.
{"dig diagnostics are not answers", ";; WARNING -- TSIG key was not used.\n", ""},
{"relative or partial lines are not names", "10.0.0.1\nns1\n", ""},
{"whitespace is trimmed", " ns1.unkin.net. \n\n", "ns1.unkin.net."},
}
for _, c := range cases {
if got := strings.Join(parseDigNames(c.out), ","); got != c.want {
t.Errorf("%s: parseDigNames(%q) = %q; want %q", c.name, c.out, got, c.want)
}
}
}
+88 -13
View File
@@ -26,39 +26,114 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil
}
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS + glue) to
// path, creating parent directories. The apex NS is the in-zone name ns1, and a
// glue A record pointing at primaryIP is included so BIND's check-integrity
// accepts the zone (an in-zone NS without an address record is a load error).
// It is only safe to call when creating a zone, as it overwrites any existing
// file. This is a placeholder that is replaced once real records are loaded.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
// renderSeedZone renders a minimal loadable zone (SOA + apex NS). nameservers
// are the names published in the apex NS RRset; when empty the in-zone name ns1
// is used. A glue A pointing at primaryIP is emitted only for a nameserver that
// falls inside the zone, because BIND refuses to load a zone whose in-zone NS
// has no address record. Out-of-zone nameservers therefore keep pod IPs out of
// the zone file entirely.
func renderSeedZone(zone, primaryIP string, nameservers []string, serial int64) string {
origin := dot(zone)
ns := "ns1." + origin
ns := make([]string, 0, len(nameservers))
for _, n := range nameservers {
ns = append(ns, dot(n))
}
if len(ns) == 0 {
ns = []string{"ns1." + origin}
}
// Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
// changed and the primary's also-notify was briefly stale) still converges
// in minutes, not the hour a 3600s refresh would impose. minimum is the
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback.
content := fmt.Sprintf(`$TTL 3600
var b strings.Builder
fmt.Fprintf(&b, `$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
300 ; refresh
60 ; retry
1209600 ; expire
60 ) ; minimum
@ IN NS %s
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
`, ns[0], origin, serial)
for _, n := range ns {
fmt.Fprintf(&b, "@ IN NS %s\n", n)
}
for _, n := range ns {
if owner, ok := InZoneOwner(n, zone); ok {
fmt.Fprintf(&b, "%s IN A %s\n", owner, primaryIP)
}
}
return b.String()
}
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)}
// InZoneOwner reports whether name sits inside zone, and if so returns its owner
// name relative to the apex ("@" for the apex itself).
func InZoneOwner(name, zone string) (string, bool) {
name, origin := dot(name), dot(zone)
switch {
case name == origin:
return "@", true
case strings.HasSuffix(name, "."+origin):
return strings.TrimSuffix(name, "."+origin), true
}
return "", false
}
// EnsureSeedZone makes path loadable without discarding live data: it probes
// the zone file and journal, moves aside whatever cannot load, and writes a
// skeleton only when there is nothing to preserve. Every caller that needs a
// zone database file on disk goes through here.
func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, nameservers []string) error {
state, err := e.ZoneDiskState(ctx, namespace, pod, path)
if err != nil {
return err
}
plan := PlanSeed(state)
if plan.Blocked != "" {
return fmt.Errorf("seed zone %s: %s", zone, plan.Blocked)
}
if !plan.WriteSeed {
return e.Quarantine(ctx, namespace, pod, path, plan)
}
content := renderSeedZone(zone, primaryIP, nameservers, plan.Serial)
cmd := []string{"sh", "-c", seedScript(path, plan, len(content))}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// seedTempSuffix names the staging file, a sibling of the zone file so the
// install is a same-filesystem rename.
const seedTempSuffix = ".seed-tmp"
// seedScript stages the skeleton, checks it arrived whole, then quarantines and
// installs it in that order. Doing all of it in one exec keeps an interrupted
// seed from leaving the PVC with no zone data, which the next reconcile would
// read as a fresh install and reseed at serial 1; the rename means a torn write
// is never visible at path. shellScript aborts the run at the first failure, so
// no step can install the skeleton over data an earlier step failed to preserve.
func seedScript(path string, plan SeedPlan, size int) string {
q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix)
cmds := []string{
fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q),
fmt.Sprintf("cat > %s", tmp),
// A stdin stream cut mid-transfer gives cat a short file and exit 0.
fmt.Sprintf("n=$(wc -c < %s | tr -d ' \\n')", tmp),
// Compared as strings: an unmeasurable size is empty, not a number, and
// a numeric test would exit 2 there and be swallowed by the if.
fmt.Sprintf("if [ \"$n\" != '%d' ]; then rm -f %s; exit 1; fi", size, tmp),
}
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
return shellScript(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q))...)
}
// AddCatalogMember registers a member zone in a catalog zone by adding the
// catalog PTR record, so secondaries auto-provision it.
func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
+75
View File
@@ -0,0 +1,75 @@
package bind
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// checkZone runs named-checkzone with full integrity checking, the same check
// named applies when loading a primary zone. Skips where the tool is absent.
func checkZone(t *testing.T, zone, content string) {
t.Helper()
bin, err := exec.LookPath("named-checkzone")
if err != nil {
t.Skip("named-checkzone not installed")
}
path := filepath.Join(t.TempDir(), "db")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command(bin, "-i", "full", zone, path).CombinedOutput(); err != nil {
t.Fatalf("zone not loadable: %v\n%s\n%s", err, out, content)
}
}
func TestRenderSeedZoneDeclaredNameservers(t *testing.T) {
got := renderSeedZone("acme.unkin.net", "10.42.6.38", []string{"ns1.unkin.net", "ns2.unkin.net."}, 7)
if strings.Contains(got, "10.42.6.38") {
t.Errorf("declared nameservers must not pull a pod IP into the zone:\n%s", got)
}
for _, want := range []string{"@ IN NS ns1.unkin.net.\n", "@ IN NS ns2.unkin.net.\n", "SOA ns1.unkin.net. hostmaster.acme.unkin.net."} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
checkZone(t, "acme.unkin.net", got)
}
// An in-zone nameserver has no address anywhere else, so the seed must glue it
// or named refuses to load the zone.
func TestRenderSeedZoneInZoneNameserverGetsGlue(t *testing.T) {
got := renderSeedZone("example.com", "10.0.0.1", []string{"ns.example.com"}, 1)
if !strings.Contains(got, "ns IN A 10.0.0.1\n") {
t.Errorf("in-zone nameserver needs glue:\n%s", got)
}
checkZone(t, "example.com", got)
}
func TestRenderSeedZoneFallbackLoads(t *testing.T) {
got := renderSeedZone("example.com", "10.0.0.1", nil, 1)
if !strings.Contains(got, "@ IN NS ns1.example.com.\n") || !strings.Contains(got, "ns1 IN A 10.0.0.1\n") {
t.Errorf("fallback seed changed shape:\n%s", got)
}
checkZone(t, "example.com", got)
}
func TestInZoneOwner(t *testing.T) {
cases := []struct {
name, zone, owner string
in bool
}{
{"ns1.example.com.", "example.com", "ns1", true},
{"example.com", "example.com.", "@", true},
{"ns1.unkin.net", "acme.unkin.net", "", false},
{"notexample.com", "example.com", "", false},
}
for _, c := range cases {
owner, in := InZoneOwner(c.name, c.zone)
if in != c.in || owner != c.owner {
t.Errorf("InZoneOwner(%q, %q) = %q, %v; want %q, %v", c.name, c.zone, owner, in, c.owner, c.in)
}
}
}
+279
View File
@@ -0,0 +1,279 @@
package bind
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func requireShell(t *testing.T, tools ...string) string {
t.Helper()
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("seed script needs %s: %v", tool, err)
}
}
return sh
}
// inFlightZone lays out the state the operator actually hit in production: a
// zone file a previous reconcile clobbered back to serial 1, with the journal
// that carries the live records up to 16.
func inFlightZone(t *testing.T) (dir, path string) {
t.Helper()
dir = t.TempDir()
path = filepath.Join(dir, "db.example.com")
if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", nil, 1)), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil {
t.Fatal(err)
}
return dir, path
}
func runSeedScript(t *testing.T, sh, path string, plan SeedPlan, content, stdin string) error {
t.Helper()
return runSeedScriptWithPath(t, sh, path, plan, content, stdin, "")
}
func runSeedScriptWithPath(t *testing.T, sh, path string, plan SeedPlan, content, stdin, pathEnv string) error {
t.Helper()
cmd := exec.Command(sh, "-c", seedScript(path, plan, len(content)))
cmd.Stdin = strings.NewReader(stdin)
if pathEnv != "" {
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
}
return cmd.Run()
}
// shimPath puts a stand-in for tool at the front of a PATH, so the generated
// script meets a failing command where a real image would meet a working one.
func shimPath(t *testing.T, tool, body string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, tool), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return dir + string(os.PathListSeparator) + os.Getenv("PATH")
}
func realTool(t *testing.T, tool string) string {
t.Helper()
p, err := exec.LookPath(tool)
if err != nil {
t.Skipf("need %s: %v", tool, err)
}
return p
}
// assertZoneUntouched checks the in-flight layout survived a failed run whole:
// live serial 1 still at path, journal still there, nothing quarantined and no
// staging file left behind.
func assertZoneUntouched(t *testing.T, dir, path string) {
t.Helper()
found := siblings(t, dir)
serial, ok := ParseZoneSerial(found[filepath.Base(path)])
if !ok || serial != 1 {
t.Errorf("zone file was replaced by a failed run: serial = (%d,%v)", serial, ok)
}
if _, ok := found[filepath.Base(JournalPath(path))]; !ok {
t.Error("the journal was lost by a failed run")
}
for name := range found {
if strings.Contains(name, quarantineMarker) {
t.Errorf("a failed run must not leave a quarantined file: %s", name)
}
}
}
func probeState(t *testing.T, sh, path string) ZoneDiskState {
t.Helper()
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
if err != nil {
t.Fatalf("probe failed: %v", err)
}
st, ok := parseZoneDiskState(string(out))
if !ok {
t.Fatalf("probe output rejected: %q", out)
}
return st
}
func siblings(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
found := map[string]string{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
found[e.Name()] = string(b)
}
return found
}
// A stdin stream cut mid-transfer gives cat a short file and exits 0. The seed
// must refuse to install it, and must not have quarantined anything on the way:
// a run that stopped here has to leave the zone exactly as it found it.
func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path))
if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal {
t.Fatalf("expected a reseed over both files, got %+v", plan)
}
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil {
t.Fatal("a truncated seed write must fail rather than install a torn zone file")
}
serial, ok := ParseZoneSerial(siblings(t, dir)[filepath.Base(path)])
if !ok || serial != 1 {
t.Errorf("the zone file was damaged by the interrupted seed: serial = (%d,%v)", serial, ok)
}
for name := range siblings(t, dir) {
if strings.Contains(name, quarantineMarker) {
t.Errorf("nothing should have been quarantined before the write landed: %s", name)
}
if strings.HasSuffix(name, seedTempSuffix) {
t.Errorf("the staging file should have been cleaned up: %s", name)
}
}
// The retry must still see the journal and reseed above it, not at 1.
retry := PlanSeed(probeState(t, sh, path))
if retry != plan {
t.Errorf("retry planned %+v, want the original %+v", retry, plan)
}
}
func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
st := probeState(t, sh, path)
if !st.ZoneFile || st.ZoneSerial != plan.Serial {
t.Errorf("installed state = %+v want serial %d", st, plan.Serial)
}
if st.Journal {
t.Error("the unreplayable journal should have been moved aside")
}
found := siblings(t, dir)
for _, want := range []string{"db.example.com.orphaned-16", "db.example.com.jnl.orphaned-16"} {
if _, ok := found[want]; !ok {
t.Errorf("missing preserved file %s: %v", want, keys(found))
}
}
if _, ok := found[filepath.Base(path)+seedTempSuffix]; ok {
t.Error("the staging file should have been renamed into place")
}
if next := PlanSeed(st); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
}
// The seed has to work on a PVC that has never held this zone, directories
// included.
func TestSeedScriptFreshInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
path := filepath.Join(t.TempDir(), "zones", "db.example.com")
plan := PlanSeed(ZoneDiskState{})
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
if st := probeState(t, sh, path); !st.ZoneFile || st.ZoneSerial != 1 {
t.Errorf("fresh install state = %+v want serial 1", st)
}
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// A rename that fails leaves live data where it is, so the install must not
// happen: the skeleton beside a higher-serial journal is the production failure
// this seed exists to avoid.
func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "mv", `case "$*" in *`+quarantineMarker+`*) exit 1;; esac
exec `+realTool(t, "mv")+` "$@"`)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed quarantine must fail the seed")
}
assertZoneUntouched(t, dir, path)
}
// The size guard has to fail closed: an image without wc cannot measure the
// staged file, and an unverified file must never be installed.
func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "wc", "exit 127")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("an unmeasurable staging file must fail the seed")
}
assertZoneUntouched(t, dir, path)
if _, ok := siblings(t, dir)[filepath.Base(path)+seedTempSuffix]; ok {
t.Error("the staging file should have been cleaned up")
}
}
func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "mkdir", "exit 1")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed mkdir must fail the seed")
}
assertZoneUntouched(t, dir, path)
}
// The probe decides whether there is anything to preserve, so a tool it cannot
// run must be an error rather than a state that reads as "nothing readable".
func TestZoneStateProbeFailedToolIsAnError(t *testing.T) {
sh := requireShell(t, "head", "od", "tr")
_, path := inFlightZone(t)
pathEnv := shimPath(t, "tr", "exit 127")
cmd := exec.Command(sh, "-c", zoneStateProbe(path))
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
out, err := cmd.Output()
if err == nil {
t.Fatalf("probe reported success without reading the journal header: %q", out)
}
}
+402
View File
@@ -0,0 +1,402 @@
package bind
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// JournalPath returns the BIND journal that accompanies a zone database file.
func JournalPath(zonePath string) string { return zonePath + ".jnl" }
// ZoneDiskState is what the primary pod's filesystem holds for one zone.
// A journal is only replayable onto a zone file whose SOA serial lies within
// [JournalBegin, JournalEnd]; outside that range BIND fails the load with
// "out of range" and the zone never comes up.
type ZoneDiskState struct {
ZoneFile bool
ZoneSerial int64
ZoneSerialOK bool
Journal bool
JournalBegin int64
JournalEnd int64
JournalOK bool
// OrphanSerial is the furthest serial recorded in a quarantine sibling on
// disk. It is the only record of how far the zone had advanced once a
// transition is interrupted between the renames and the new file landing.
OrphanSerial int64
OrphanSerialOK bool
}
// SeedPlan is the decision taken before writing a skeleton zone file.
type SeedPlan struct {
WriteSeed bool
Serial int64
QuarantineZoneFile bool
QuarantineJournal bool
QuarantineSuffix string
// Blocked names the reason the disk state could not be judged safely. The
// caller must touch nothing and surface it.
Blocked string
}
// PlanSeed decides how to make a zone loadable without discarding live data.
// Nothing is ever deleted: unusable files are renamed aside so they stay
// recoverable on the PVC.
func PlanSeed(st ZoneDiskState) SeedPlan {
suffix := quarantineSuffix(st)
if !st.ZoneFile {
// The journal's serial range is the only evidence of how far the zone
// had advanced; without it a seed could regress below live data.
if st.Journal && !st.JournalOK {
return SeedPlan{Blocked: "journal present but its header could not be read"}
}
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineJournal: st.Journal,
QuarantineSuffix: suffix,
}
}
if !st.Journal || !st.JournalOK || !st.ZoneSerialOK {
return SeedPlan{}
}
switch {
case serialLT(st.ZoneSerial, st.JournalBegin):
// The file has regressed behind the journal: it is the skeleton a
// previous reconcile clobbered it with. Keep both aside and reseed
// above the journal so secondaries still see a serial increase.
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineZoneFile: true,
QuarantineJournal: true,
QuarantineSuffix: suffix,
}
case serialLT(st.JournalEnd, st.ZoneSerial):
return SeedPlan{QuarantineJournal: true, QuarantineSuffix: suffix}
default:
return SeedPlan{}
}
}
// highestSerial reports the furthest serial on disk. The second result is false
// when no serial could be read at all: 0 is a legitimate serial, so it cannot
// double as "nothing found" in RFC 1982 sequence space. RFC 1982 ordering is
// not total, so the fold is order-dependent once the inputs span more than
// 2^31; a zone cannot advance that far between reconciles.
func highestSerial(st ZoneDiskState) (int64, bool) {
var h int64
var known bool
if st.ZoneFile && st.ZoneSerialOK {
h, known = st.ZoneSerial, true
}
if st.Journal && st.JournalOK && (!known || serialLT(h, st.JournalEnd)) {
h, known = st.JournalEnd, true
}
if st.OrphanSerialOK && (!known || serialLT(h, st.OrphanSerial)) {
h, known = st.OrphanSerial, true
}
return h, known
}
func quarantineSuffix(st ZoneDiskState) string {
h, _ := highestSerial(st)
return quarantineMarker + strconv.FormatInt(h, 10)
}
// parseOrphanSerial reads the serial back out of a name moveAside produced,
// with or without the counter it appends when the destination is taken.
func parseOrphanSerial(name string) (int64, bool) {
i := strings.LastIndex(name, quarantineMarker)
if i < 0 {
return 0, false
}
digits := name[i+len(quarantineMarker):]
n := 0
for n < len(digits) && digits[n] >= '0' && digits[n] <= '9' {
n++
}
if n == 0 {
return 0, false
}
serial, err := strconv.ParseUint(digits[:n], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
func orphanSerial(names []string) (int64, bool) {
var h int64
var known bool
for _, name := range names {
s, ok := parseOrphanSerial(name)
if !ok {
continue
}
if !known || serialLT(h, s) {
h, known = s, true
}
}
return h, known
}
func nextSerial(st ZoneDiskState) int64 {
h, known := highestSerial(st)
if !known {
return 1
}
next := int64(uint32(h) + 1)
if next == 0 {
return 1
}
return next
}
// serialLT compares DNS serials in RFC 1982 sequence space.
func serialLT(a, b int64) bool {
if a == b {
return false
}
return uint32(b)-uint32(a) < 1<<31
}
// ParseZoneSerial extracts the SOA serial from the head of a zone file, in
// both the operator's seed layout and BIND's own multi-line dump layout.
func ParseZoneSerial(content string) (int64, bool) {
var tokens []string
for _, line := range strings.Split(content, "\n") {
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.ReplaceAll(line, "(", " ( ")
line = strings.ReplaceAll(line, ")", " ) ")
tokens = append(tokens, strings.Fields(line)...)
}
for i, tok := range tokens {
if !strings.EqualFold(tok, "SOA") {
continue
}
rest := tokens[i+1:]
if len(rest) < 3 {
return 0, false
}
rest = rest[2:] // MNAME, RNAME
if rest[0] == "(" {
rest = rest[1:]
}
if len(rest) == 0 {
return 0, false
}
serial, err := strconv.ParseUint(rest[0], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
return 0, false
}
// journalFormats are the zero-padded 16-byte format fields BIND writes and
// compares whole (lib/dns/journal.c).
var journalFormats = [][16]byte{
journalFormat(";BIND LOG V9\n"),
journalFormat(";BIND LOG V9.2\n"),
}
func journalFormat(magic string) [16]byte {
var f [16]byte
copy(f[:], magic)
return f
}
// parseJournalHeader reads the begin and end serials from a BIND journal
// header: a 16-byte format magic followed by two {serial,offset} big-endian
// pairs.
func parseJournalHeader(b []byte) (begin, end int64, ok bool) {
if len(b) < 28 {
return 0, 0, false
}
var format [16]byte
copy(format[:], b[:16])
known := false
for _, f := range journalFormats {
if format == f {
known = true
break
}
}
if !known {
return 0, 0, false
}
return int64(beUint32(b[16:20])), int64(beUint32(b[24:28])), true
}
func beUint32(b []byte) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
// Probe framing. Every field is declared exactly once between the sentinels, so
// stdout that was truncated, empty or partially written is rejected rather than
// read as "fresh install".
const (
probeBegin = "zonestate-begin-v1"
probeEnd = "zonestate-end-v1"
probeHeadOpen = "head<<"
probeHeadShut = ">>head"
probeOrphanOpen = "orphans<<"
probeOrphanShut = ">>orphans"
// quarantineMarker joins a preserved file to the serial floor a reseed must
// stay above, which highestSerial may take from a sibling rather than from
// the renamed file itself.
quarantineMarker = ".orphaned-"
)
// zoneStateProbe reads only the head of the zone file: the SOA is the first
// record in both layouts the operator has to read.
func zoneStateProbe(path string) string {
zone, jnl := shellQuote(path), shellQuote(JournalPath(path))
return shellScript(
fmt.Sprintf("printf '%s\\n'", probeBegin),
fmt.Sprintf("if [ -f %s ]; then printf 'zonefile=1\\n%s\\n'; head -c 4096 %s; printf '\\n%s\\n'; else printf 'zonefile=0\\n'; fi",
zone, probeHeadOpen, zone, probeHeadShut),
// The hex is folded in its own assignment so a failing tr aborts the
// probe instead of reporting an unreadable journal header.
fmt.Sprintf("if [ -f %s ]; then printf 'journal=1\\n'; hdr=$(od -An -v -tx1 -N32 %s); hdr=$(printf '%%s' \"$hdr\" | tr -d ' \\n'); printf 'jnl=%%s\\n' \"$hdr\"; else printf 'journal=0\\n'; fi",
jnl, jnl),
// The quarantine siblings outlive the files they replaced, so they are
// the floor a reseed must stay above after an interrupted transition.
fmt.Sprintf("printf '%s\\n'\nfor f in %s* %s*; do if [ -e \"$f\" ]; then printf '%%s\\n' \"$f\"; fi; done\nprintf '%s\\n'",
probeOrphanOpen, shellQuote(path+quarantineMarker), shellQuote(JournalPath(path)+quarantineMarker), probeOrphanShut),
fmt.Sprintf("printf '%s\\n'", probeEnd),
)
}
// parseZoneDiskState returns false unless the probe output is complete: a
// half-written or empty probe must never be mistaken for an empty filesystem.
func parseZoneDiskState(out string) (ZoneDiskState, bool) {
var st ZoneDiskState
var head, orphans []string
var sawBegin, sawEnd, inHead, headShut, inOrphans, orphansShut bool
var zoneDecls, journalDecls, headerDecls, orphanDecls int
for _, line := range strings.Split(out, "\n") {
switch {
case inHead && line == probeHeadShut:
inHead, headShut = false, true
case inHead:
head = append(head, line)
case inOrphans && line == probeOrphanShut:
inOrphans, orphansShut = false, true
case inOrphans:
if line != "" {
orphans = append(orphans, line)
}
case line == probeBegin:
sawBegin = true
case line == probeEnd:
sawEnd = true
case line == probeHeadOpen:
inHead = true
case line == probeOrphanOpen:
inOrphans, orphanDecls = true, orphanDecls+1
case line == "zonefile=1":
st.ZoneFile = true
zoneDecls++
case line == "zonefile=0":
zoneDecls++
case line == "journal=1":
st.Journal = true
journalDecls++
case line == "journal=0":
journalDecls++
case strings.HasPrefix(line, "jnl="):
headerDecls++
if raw, err := hex.DecodeString(strings.TrimPrefix(line, "jnl=")); err == nil {
st.JournalBegin, st.JournalEnd, st.JournalOK = parseJournalHeader(raw)
}
}
}
switch {
case !sawBegin || !sawEnd || inHead || inOrphans:
return ZoneDiskState{}, false
case orphanDecls != 1 || !orphansShut:
return ZoneDiskState{}, false
case zoneDecls != 1 || journalDecls != 1:
return ZoneDiskState{}, false
case st.ZoneFile && !headShut:
return ZoneDiskState{}, false
case st.Journal && headerDecls != 1:
return ZoneDiskState{}, false
case !st.Journal && headerDecls != 0:
return ZoneDiskState{}, false
}
if st.ZoneFile {
st.ZoneSerial, st.ZoneSerialOK = ParseZoneSerial(strings.Join(head, "\n"))
}
st.OrphanSerial, st.OrphanSerialOK = orphanSerial(orphans)
return st, true
}
// ZoneDiskState inspects the zone database file and journal on the pod.
func (e *Executor) ZoneDiskState(ctx context.Context, namespace, pod, path string) (ZoneDiskState, error) {
out, err := e.Exec(ctx, namespace, pod, []string{"sh", "-c", zoneStateProbe(path)}, "")
if err != nil {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: %w (out: %s)", path, err, out)
}
st, ok := parseZoneDiskState(out)
if !ok {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: incomplete probe output %q", path, out)
}
return st, nil
}
// Quarantine renames the files the plan marks unusable, leaving them on the
// PVC under a suffixed name.
func (e *Executor) Quarantine(ctx context.Context, namespace, pod, path string, plan SeedPlan) error {
var cmds []string
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
if len(cmds) == 0 {
return nil
}
cmd := []string{"sh", "-c", shellScript(cmds...)}
if out, err := e.Exec(ctx, namespace, pod, cmd, ""); err != nil {
return fmt.Errorf("quarantine zone files %s: %w (out: %s)", path, err, out)
}
return nil
}
// moveAside renames path out of the way. A repeat incident can compute the same
// suffix, so the destination is numbered until it is free: a preserved copy is
// never overwritten.
func moveAside(path, suffix string) string {
src, dest := shellQuote(path), shellQuote(path+suffix)
return fmt.Sprintf("if [ -f %s ]; then d=%s; n=0; while [ -e \"$d\" ]; do n=$((n+1)); d=%s.$n; done; mv -- %s \"$d\"; fi",
src, dest, dest, src)
}
// shellQuote renders s as a single POSIX shell word.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// shellScript joins commands into a script that stops at the first failure and
// exits non-zero. A plain script runs every line regardless of the previous
// one's status, which would let an install proceed over a failed quarantine and
// still report success to the caller.
func shellScript(cmds ...string) string {
return strings.Join(append([]string{"set -e"}, cmds...), "\n")
}
+595
View File
@@ -0,0 +1,595 @@
package bind
import (
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// probeOut frames body the way zoneStateProbe does, so the parser is exercised
// on realistic input.
func probeOut(body ...string) string {
body = append(body, probeOrphanOpen, probeOrphanShut)
return strings.Join(append(append([]string{probeBegin}, body...), probeEnd, ""), "\n")
}
func journalHeader(magic string, begin, end uint32) []byte {
b := make([]byte, 32)
copy(b, magic)
put := func(off int, v uint32) {
b[off] = byte(v >> 24)
b[off+1] = byte(v >> 16)
b[off+2] = byte(v >> 8)
b[off+3] = byte(v)
}
put(16, begin)
put(24, end)
return b
}
func TestParseZoneSerial(t *testing.T) {
bindDump := `$ORIGIN .
$TTL 3600 ; 1 hour
k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.unkin.net. (
16 ; serial
300 ; refresh (5 minutes)
60 ; retry (1 minute)
1209600 ; expire (2 weeks)
60 ; minimum (1 minute)
)
`
cases := []struct {
name string
content string
want int64
ok bool
}{
{"bind dump", bindDump, 16, true},
{"seed", renderSeedZone("example.com", "10.0.0.1", nil, 42), 42, true},
{"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true},
{"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true},
{"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false},
{"truncated", "@ IN SOA ns.\n", 0, false},
{"non numeric serial", "@ IN SOA ns. host. ( abc 300 )\n", 0, false},
}
for _, c := range cases {
got, ok := ParseZoneSerial(c.content)
if got != c.want || ok != c.ok {
t.Errorf("%s: ParseZoneSerial = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
}
func TestParseJournalHeader(t *testing.T) {
begin, end, ok := parseJournalHeader(journalHeader(";BIND LOG V9.2\n", 10, 16))
if !ok || begin != 10 || end != 16 {
t.Errorf("V9.2 header = (%d,%d,%v) want (10,16,true)", begin, end, ok)
}
if _, _, ok := parseJournalHeader(journalHeader("not a journal\n", 10, 16)); ok {
t.Error("bad magic should not parse")
}
if _, _, ok := parseJournalHeader([]byte(";BIND LOG V9.2\n")); ok {
t.Error("truncated header should not parse")
}
}
func TestParseZoneDiskState(t *testing.T) {
out := probeOut(
"zonefile=1",
probeHeadOpen,
"@ IN SOA ns. host. ( 5 300 60 1209600 60 )",
probeHeadShut,
"journal=1",
"jnl="+hex.EncodeToString(journalHeader(";BIND LOG V9.2\n", 3, 8)),
)
st, ok := parseZoneDiskState(out)
if !ok {
t.Fatalf("well-formed probe rejected: %q", out)
}
if !st.ZoneFile || !st.ZoneSerialOK || st.ZoneSerial != 5 {
t.Errorf("zone file state wrong: %+v", st)
}
if !st.Journal || !st.JournalOK || st.JournalBegin != 3 || st.JournalEnd != 8 {
t.Errorf("journal state wrong: %+v", st)
}
empty, ok := parseZoneDiskState(probeOut("zonefile=0", "journal=0"))
if !ok || empty.ZoneFile || empty.Journal {
t.Errorf("empty state wrong: %+v (ok=%v)", empty, ok)
}
}
// A probe that returns nothing useful must not be read as "fresh install": the
// shell exits 0 after its last printf and stderr is dropped on success, so a
// missing tool or a truncated stream is otherwise invisible.
func TestParseZoneDiskStateRejectsDegradedProbe(t *testing.T) {
live := probeHeadOpen + "\n@ IN SOA ns. host. ( 5 300 60 1209600 60 )\n" + probeHeadShut
cases := map[string]string{
"empty output": "",
"whitespace only": "\n\n",
"no framing": "zonefile=0\njournal=0\n",
"no terminator": probeBegin + "\nzonefile=0\njournal=0\n",
"cut before zone file": probeBegin + "\n",
"cut mid head": probeBegin + "\nzonefile=1\n" + probeHeadOpen + "\n@ IN SOA ns. host. ( 5",
"cut after head": probeBegin + "\nzonefile=1\n" + live + "\n",
"no zone declaration": probeOut("journal=0"),
"no journal branch": probeOut("zonefile=1", live),
"journal without hex": probeOut("zonefile=0", "journal=1"),
"duplicate zone decl": probeOut("zonefile=0", "zonefile=1", "journal=0"),
"header without file": probeOut("zonefile=0", "journal=0", "jnl=00"),
"no orphan block": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeEnd, ""}, "\n"),
"orphan block unterminated": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeEnd, ""}, "\n"),
"duplicate orphan block": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeOrphanShut,
probeOrphanOpen, probeOrphanShut, probeEnd, ""}, "\n"),
}
for name, out := range cases {
// The zero state is a legitimate fresh install, so rejection has to
// happen here: ZoneDiskState turns it into an error and nothing plans.
if st, ok := parseZoneDiskState(out); ok {
t.Errorf("%s: degraded probe accepted as %+v", name, st)
}
}
}
// applyPlan models what the pod filesystem looks like after the plan runs, so
// a second PlanSeed can be checked for idempotence.
func applyPlan(st ZoneDiskState, p SeedPlan) ZoneDiskState {
if p.QuarantineJournal {
st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false
}
if p.QuarantineZoneFile {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false
}
if p.WriteSeed {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = true, p.Serial, true
}
return st
}
func TestPlanSeedFreshInstall(t *testing.T) {
p := PlanSeed(ZoneDiskState{})
if !p.WriteSeed || p.Serial != 1 {
t.Fatalf("fresh install should seed at serial 1, got %+v", p)
}
if p.QuarantineZoneFile || p.QuarantineJournal {
t.Errorf("fresh install should quarantine nothing, got %+v", p)
}
}
func TestPlanSeedOrphanJournal(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true}
p := PlanSeed(st)
if !p.WriteSeed {
t.Fatalf("orphan journal should still seed, got %+v", p)
}
if !p.QuarantineJournal || p.QuarantineZoneFile {
t.Errorf("only the journal should be quarantined, got %+v", p)
}
if p.Serial <= 16 {
t.Errorf("seed serial %d must exceed the journal end serial 16", p.Serial)
}
if p.QuarantineSuffix != ".orphaned-16" {
t.Errorf("quarantine suffix should be deterministic, got %q", p.QuarantineSuffix)
}
}
func TestPlanSeedFileRegressedBehindJournal(t *testing.T) {
// The production failure: a skeleton at serial 1 left next to a journal at
// serial 16, which BIND refuses to replay ("out of range").
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if !p.WriteSeed || p.Serial <= 16 {
t.Fatalf("reseed must land above the journal end serial, got %+v", p)
}
if !p.QuarantineJournal || !p.QuarantineZoneFile {
t.Errorf("the unloadable pair should both be moved aside, got %+v", p)
}
after := applyPlan(st, p)
if after.Journal {
t.Error("journal should be gone after quarantine")
}
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
if after.ZoneSerial != p.Serial {
t.Errorf("second reconcile changed the serial: %d want %d", after.ZoneSerial, p.Serial)
}
}
func TestPlanSeedLeavesHealthyZoneAlone(t *testing.T) {
cases := []struct {
name string
st ZoneDiskState
}{
{"file and covering journal", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file at journal end", ZoneDiskState{
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file without journal", ZoneDiskState{ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true}},
{"unreadable journal header", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true, Journal: true,
}},
{"unparsable zone file", ZoneDiskState{ZoneFile: true}},
}
for _, c := range cases {
if p := PlanSeed(c.st); p != (SeedPlan{}) {
t.Errorf("%s: live data must not be touched, got %+v", c.name, p)
}
}
}
func TestPlanSeedStaleJournalBehindFile(t *testing.T) {
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 30, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if p.WriteSeed || p.QuarantineZoneFile {
t.Fatalf("a file ahead of its journal is live data, got %+v", p)
}
if !p.QuarantineJournal {
t.Errorf("the unreplayable journal should be moved aside, got %+v", p)
}
if next := PlanSeed(applyPlan(st, p)); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
}
func TestPlanSeedFreshInstallIdempotent(t *testing.T) {
st := ZoneDiskState{}
p := PlanSeed(st)
after := applyPlan(st, p)
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Fatalf("second reconcile of a fresh zone should be a no-op, got %+v", next)
}
if after.ZoneSerial != 1 {
t.Errorf("serial reset on second reconcile: %d", after.ZoneSerial)
}
}
func TestPlanSeedSerialWrap(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 1 << 31, JournalEnd: 1<<32 - 1, JournalOK: true}
if h, known := highestSerial(st); !known || h != 1<<32-1 {
t.Fatalf("highestSerial = (%d,%v) want (%d,true): the wrap branch is not being reached", h, known, int64(1)<<32-1)
}
p := PlanSeed(st)
if p.Serial != 1 {
t.Fatalf("serial after wrap = %d want 1", p.Serial)
}
if !serialLT(st.JournalEnd, p.Serial) {
t.Errorf("wrapped serial %d must still sort after journal end %d", p.Serial, st.JournalEnd)
}
}
// Serials above 2^31 must not be flattened to 1: RFC 1982 comparison against a
// zero placeholder reads them as older, and secondaries reject the regression.
func TestPlanSeedHighSerialJournal(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 1<<31 - 10, JournalEnd: 1 << 31, JournalOK: true}
p := PlanSeed(st)
if !p.WriteSeed {
t.Fatalf("orphan journal should still seed, got %+v", p)
}
if p.Serial != 1<<31+1 {
t.Errorf("seed serial = %d want %d", p.Serial, int64(1)<<31+1)
}
if !serialLT(st.JournalEnd, p.Serial) {
t.Errorf("seed serial %d must sort after journal end %d", p.Serial, st.JournalEnd)
}
if p.QuarantineSuffix != ".orphaned-2147483648" {
t.Errorf("quarantine suffix = %q", p.QuarantineSuffix)
}
}
// An orphan journal whose header will not parse (no od, EACCES, short read)
// hides how far the zone had advanced, so quarantining it and reseeding at 1
// would regress live data.
func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) {
p := PlanSeed(ZoneDiskState{Journal: true})
if p.Blocked == "" {
t.Fatalf("an unreadable orphan journal must block, got %+v", p)
}
if p.WriteSeed || p.QuarantineJournal || p.QuarantineZoneFile {
t.Errorf("a blocked plan must touch nothing, got %+v", p)
}
}
func TestSeedZoneRoundTripsThroughParser(t *testing.T) {
content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", nil, 17)
got, ok := ParseZoneSerial(content)
if !ok || got != 17 {
t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok)
}
}
func TestQuarantinePathsAreSuffixed(t *testing.T) {
path := ZoneFilePath("example.com")
if JournalPath(path) != path+".jnl" {
t.Fatalf("journal path = %q", JournalPath(path))
}
cmd := moveAside(JournalPath(path), ".orphaned-16")
if !strings.Contains(cmd, "'"+path+".jnl.orphaned-16'") {
t.Errorf("quarantine command should rename, not delete: %s", cmd)
}
if strings.Contains(cmd, "rm ") {
t.Errorf("quarantine must never delete: %s", cmd)
}
}
// A repeat incident computes the same suffix, so the rename must not overwrite
// the copy preserved by the previous one.
func TestMoveAsidePreservesEarlierQuarantine(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "db.example.com")
for _, content := range []string{"first", "second"} {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(sh, "-c", moveAside(path, ".orphaned-16")).CombinedOutput()
if err != nil {
t.Fatalf("moveAside(%s): %v (%s)", content, err, out)
}
}
if _, err := os.Stat(path); err == nil {
t.Error("the quarantined file should have been renamed away")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
found[string(b)] = true
}
for _, want := range []string{"first", "second"} {
if !found[want] {
t.Errorf("quarantine destroyed %q: %v", want, found)
}
}
}
func TestParseJournalHeaderRejectsPaddingGarbage(t *testing.T) {
h := journalHeader(";BIND LOG V9\n", 10, 16)
h[15] = 'x'
if _, _, ok := parseJournalHeader(h); ok {
t.Error("format field must match all 16 bytes")
}
}
func TestShellQuoteEscapesQuotes(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
evil := `a'; touch pwned; echo '`
out, err := exec.Command(sh, "-c", "printf %s "+shellQuote(evil)).Output()
if err != nil {
t.Fatal(err)
}
if string(out) != evil {
t.Errorf("shellQuote round trip = %q want %q", out, evil)
}
}
// The probe is shell, so run it and check the parser agrees with what is
// actually on disk; a syntax slip or a missing field would otherwise only
// surface as a seed over live data.
func TestZoneStateProbeRoundTrip(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
for _, tool := range []string{"head", "od", "tr"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("probe needs %s: %v", tool, err)
}
}
cases := []struct {
name string
zone string
jnl []byte
orphans []string
want ZoneDiskState
}{
{name: "fresh install"},
{
name: "zone file only",
zone: renderSeedZone("example.com", "10.0.0.1", nil, 42),
want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true},
},
{
name: "zone file and journal",
zone: renderSeedZone("example.com", "10.0.0.1", nil, 12),
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
},
},
{
name: "orphan journal",
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true},
},
{
name: "journal with unreadable header",
jnl: []byte("garbage"),
want: ZoneDiskState{Journal: true},
},
{
name: "quarantine evidence only",
orphans: []string{".orphaned-16", ".jnl.orphaned-16"},
want: ZoneDiskState{OrphanSerial: 16, OrphanSerialOK: true},
},
{
name: "repeat quarantine keeps the highest serial",
orphans: []string{".orphaned-16", ".orphaned-30", ".orphaned-30.1"},
want: ZoneDiskState{OrphanSerial: 30, OrphanSerialOK: true},
},
{
name: "live zone beside old quarantine evidence",
zone: renderSeedZone("example.com", "10.0.0.1", nil, 42),
orphans: []string{".orphaned-16"},
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true,
OrphanSerial: 16, OrphanSerialOK: true,
},
},
}
for _, c := range cases {
path := filepath.Join(t.TempDir(), "db.example.com")
if c.zone != "" {
if err := os.WriteFile(path, []byte(c.zone), 0o600); err != nil {
t.Fatal(err)
}
}
if c.jnl != nil {
if err := os.WriteFile(JournalPath(path), c.jnl, 0o600); err != nil {
t.Fatal(err)
}
}
for _, suffix := range c.orphans {
if err := os.WriteFile(path+suffix, []byte("preserved"), 0o600); err != nil {
t.Fatal(err)
}
}
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
if err != nil {
t.Fatalf("%s: probe failed: %v", c.name, err)
}
got, ok := parseZoneDiskState(string(out))
if !ok {
t.Errorf("%s: probe output rejected: %q", c.name, out)
continue
}
if got != c.want {
t.Errorf("%s: state = %+v want %+v (out %q)", c.name, got, c.want, out)
}
}
}
// applyPlanInterrupted models the plan being cut off between the quarantine
// renames and the new zone file landing: the PVC holds no zone data at all, and
// the .orphaned-<serial> siblings are the only record of how far it had got.
func applyPlanInterrupted(st ZoneDiskState, p SeedPlan) ZoneDiskState {
h, known := highestSerial(st)
if p.QuarantineJournal {
st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false
}
if p.QuarantineZoneFile {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false
}
if known && (p.QuarantineJournal || p.QuarantineZoneFile) {
st.OrphanSerial, st.OrphanSerialOK = h, true
}
return st
}
// A reconcile that quarantined and then failed to write leaves a directory that
// looks fresh. Seeding it at 1 loads cleanly but every secondary holding the
// old serial refuses the transfer, so the zone goes permanently stale.
func TestPlanSeedInterruptedTransitionDoesNotRegressSerial(t *testing.T) {
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
first := PlanSeed(st)
if !first.WriteSeed {
t.Fatalf("a file behind its journal should be reseeded, got %+v", first)
}
after := applyPlanInterrupted(st, first)
if after.ZoneFile || after.Journal {
t.Fatalf("the interrupted state should hold no zone data, got %+v", after)
}
retry := PlanSeed(after)
if !retry.WriteSeed {
t.Fatalf("a zone with nothing on disk must still be seeded, got %+v", retry)
}
if !serialLT(16, retry.Serial) {
t.Errorf("reseed at %d regressed below the quarantined serial 16", retry.Serial)
}
if retry.Serial != first.Serial {
t.Errorf("retry seeded at %d, the interrupted attempt planned %d", retry.Serial, first.Serial)
}
if retry.QuarantineZoneFile || retry.QuarantineJournal {
t.Errorf("there is nothing left to quarantine, got %+v", retry)
}
if next := PlanSeed(applyPlan(after, retry)); next != (SeedPlan{}) {
t.Errorf("third reconcile should be a no-op, got %+v", next)
}
}
// Quarantine evidence is a floor, never a trigger: it must not disturb a zone
// that is healthy now, and it must not unblock an unjudgeable journal.
func TestPlanSeedOrphanEvidenceDoesNotDisturbLiveData(t *testing.T) {
healthy := ZoneDiskState{
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
OrphanSerial: 99, OrphanSerialOK: true,
}
if p := PlanSeed(healthy); p != (SeedPlan{}) {
t.Errorf("a healthy zone must not be touched, got %+v", p)
}
blocked := ZoneDiskState{Journal: true, OrphanSerial: 99, OrphanSerialOK: true}
if p := PlanSeed(blocked); p.Blocked == "" {
t.Errorf("an unreadable orphan journal must still block, got %+v", p)
}
}
func TestParseOrphanSerial(t *testing.T) {
base := ZoneFilePath("example.com")
cases := []struct {
name string
want int64
ok bool
}{
{base + ".orphaned-16", 16, true},
{JournalPath(base) + ".orphaned-16", 16, true},
{base + ".orphaned-16.3", 16, true},
{base + ".orphaned-4294967295", 4294967295, true},
{base + ".orphaned-", 0, false},
{base + ".orphaned-abc", 0, false},
{base + ".orphaned-4294967296", 0, false},
{base, 0, false},
{base + ".jnl", 0, false},
}
for _, c := range cases {
got, ok := parseOrphanSerial(c.name)
if got != c.want || ok != c.ok {
t.Errorf("parseOrphanSerial(%q) = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
if _, ok := orphanSerial(nil); ok {
t.Error("no siblings means no recorded serial, not serial 0")
}
// Serial 0 is legitimate and must not read as "nothing found".
if h, ok := orphanSerial([]string{base + ".orphaned-0"}); !ok || h != 0 {
t.Errorf("orphanSerial = (%d,%v) want (0,true)", h, ok)
}
}
+165
View File
@@ -0,0 +1,165 @@
package controller
import (
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
func zoneWith(spec bindv1alpha1.BindZoneSpec) *bindv1alpha1.BindZone {
spec.ZoneName = "acme.unkin.net"
return &bindv1alpha1.BindZone{Spec: spec}
}
func testCluster() *bindv1alpha1.BindCluster {
c := &bindv1alpha1.BindCluster{}
c.Name, c.Namespace = "auth", "bind-internal"
return c
}
const stableNS = "auth-0.auth-headless.bind-internal.svc.cluster.local."
func TestZoneNameservers(t *testing.T) {
ttl60 := int32(60)
cases := []struct {
name string
spec bindv1alpha1.BindZoneSpec
want []string
ttl int32
declared bool
}{
{"fallback is out-of-zone, so it needs no glue", bindv1alpha1.BindZoneSpec{}, []string{stableNS}, 3600, false},
{"declared wins", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}, []string{"ns1.unkin.net."}, 3600, true},
{"spec.defaultTTL applies", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60}, []string{"ns1.unkin.net."}, 60, true},
// An apex NS in spec.records cannot converge on its own: BIND ignores an
// RRset-wide delete at the apex, so it has to go through the apex path.
{"apex NS in records counts as declared", bindv1alpha1.BindZoneSpec{Records: []bindv1alpha1.Record{
{Name: "@", Type: "ns", Values: []string{"a.ns.unkin.net.", "b.ns.unkin.net."}},
{Name: "www", Type: "A", Values: []string{"10.0.0.1"}},
}}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600, true},
// A TTL on the apex NS record itself must survive the fold.
{"record TTL beats the zone default", bindv1alpha1.BindZoneSpec{DefaultTTL: 3600, Records: []bindv1alpha1.Record{
{Name: "@", Type: "NS", TTL: &ttl60, Values: []string{"a.ns.unkin.net."}},
}}, []string{"a.ns.unkin.net."}, 60, true},
{"spec.nameservers beats records", bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "@", Type: "NS", Values: []string{"other.unkin.net."}}},
}, []string{"ns1.unkin.net."}, 3600, true},
}
for _, c := range cases {
got, ttl, declared := zoneNameservers(zoneWith(c.spec), testCluster())
if declared != c.declared || ttl != c.ttl || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("%s: got %v/%d/%v; want %v/%d/%v", c.name, got, ttl, declared, c.want, c.ttl, c.declared)
}
}
}
// A query that cannot see the zone returns nothing, which must not be read as an
// empty apex: retracting blind means deleting the last NS record, which named
// rejects, leaving the zone stuck.
func TestApexNSUpdatesUnreadableLiveSetIsAdditive(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, nil, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true},
})
}
// The apex NS RRset must be converged per record: an RRset-wide delete at the
// apex is ignored by BIND, which would leave the placeholder published alongside
// the real nameservers. Retracting an in-zone name takes its glue with it.
func TestApexNSUpdatesConverges(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 60)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
})
}
// Glue retirement is not special-cased to the name ns1: the seed glues every
// declared in-zone nameserver.
func TestApexNSUpdatesRetiresAnyInZoneGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net."}})
got := apexNSUpdates(zone, []string{"a.ns.unkin.net."}, []string{"dns.acme.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"a.ns.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"dns.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "dns.acme.unkin.net.", Type: "A", Delete: true},
})
}
func TestApexNSUpdatesNoopWhenConverged(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}})
// Case differs: DNS names compare case-insensitively, so this is converged.
if got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"NS1.Unkin.Net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// Changing the declared set replaces only what changed, keeping the overlap.
func TestApexNSUpdatesPartialChange(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net.", "c.ns.unkin.net."}})
got := apexNSUpdates(zone, []string{"a.ns.unkin.net.", "c.ns.unkin.net."}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"c.ns.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"b.ns.unkin.net."}, PerValue: true, Delete: true},
})
}
// A declared in-zone nameserver owns its glue; removing it would fail named's
// post-update nameserver sanity check.
func TestApexNSUpdatesKeepsNeededGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net."}})
if got := apexNSUpdates(zone, []string{"ns1.acme.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// spec.records owning the ns1 address means the glue is real data, not the seed
// placeholder.
func TestApexNSUpdatesLeavesRecordOwnedGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "ns1", Type: "a", Values: []string{"10.0.0.53"}}},
})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
})
}
// The apex NS is converged by the apex path, so it must not also be emitted as a
// record: an RRset-wide delete there is ignored and the add would append.
func TestRecordsToUpdatesSkipsApexNS(t *testing.T) {
records := []bindv1alpha1.Record{
{Name: "@", Type: "NS", Values: []string{"a.ns.unkin.net."}},
{Name: "sub", Type: "NS", Values: []string{"d.ns.unkin.net."}},
{Name: "@", Type: "MX", Values: []string{"10 mail.unkin.net."}},
}
got := recordsToUpdates("acme.unkin.net", records, 3600)
if len(got) != 2 || got[0].FQDN != "sub.acme.unkin.net." || got[1].Type != "MX" {
t.Fatalf("got %+v", got)
}
}
func assertUpdates(t *testing.T, got, want []bind.RecordUpdate) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d updates %+v; want %d %+v", len(got), got, len(want), want)
}
for i := range want {
g, w := got[i], want[i]
if g.FQDN != w.FQDN || g.Type != w.Type || g.TTL != w.TTL || g.Delete != w.Delete || g.PerValue != w.PerValue {
t.Errorf("update %d = %+v; want %+v", i, g, w)
continue
}
if strings.Join(g.Values, ",") != strings.Join(w.Values, ",") {
t.Errorf("update %d values = %v; want %v", i, g.Values, w.Values)
}
}
}
@@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if primaryIP == "" {
return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, 1); err != nil {
if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, clusterNameservers(cluster)); err != nil {
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if primaryIP == "" {
return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, 1); err != nil {
if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, clusterNameservers(cluster)); err != nil {
return r.fail(ctx, &policy, "SeedFailed", err.Error())
}
}
+36 -7
View File
@@ -99,13 +99,18 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
nameservers, nsTTL, nsDeclared := zoneNameservers(&zone, cluster)
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), primaryIP, 1); err != nil {
// The zone is absent from named's memory, but its database file and
// journal may still be on the PVC from a previous incarnation.
path := bind.ZoneFilePath(zone.Spec.ZoneName)
if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP, nameservers); err != nil {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
@@ -113,18 +118,42 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
}
// Seed static records (primary zones only).
// Records are applied before the apex NS: an in-zone nameserver's address
// record has to exist first, or named rejects the apex transaction with a
// post-update nameserver sanity check failure.
recordCount := 0
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
records := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
if isPrimaryType(zone.Spec.Type) && (len(records) > 0 || nsDeclared) {
creds, err := r.zoneUpdateCreds(ctx, &zone)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
}
updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil {
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
if len(records) > 0 {
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, records); err != nil {
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
}
recordCount = len(records)
}
// Converge the apex NS on every pass, not only at seed time, so a zone
// seeded with the placeholder moves onto its real nameservers. Only for a
// zone that declared them: otherwise the operator would fight whoever else
// manages the RRset.
if nsDeclared {
live, err := r.Exec.ApexNS(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ApexNSQueryFailed", err.Error())
}
if len(live) == 0 {
logger.Info("apex NS not readable, publishing without retracting", "zone", zone.Spec.ZoneName)
}
if apex := apexNSUpdates(&zone, nameservers, live, nsTTL); len(apex) > 0 {
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, apex); err != nil {
return r.setPhase(ctx, &zone, "Error", "ApexNSSyncFailed",
fmt.Sprintf("%s (a nameserver inside the zone needs an address record here)", err))
}
logger.Info("apex NS converged", "zone", zone.Spec.ZoneName, "nameservers", nameservers)
}
}
recordCount = len(updates)
}
// Register in the catalog so secondaries auto-provision.
+121
View File
@@ -42,6 +42,11 @@ func fqdn(name, zone string) string {
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
updates := make([]bind.RecordUpdate, 0, len(records))
for _, rec := range records {
// The apex NS RRset is converged by apexNSUpdates: an RRset-wide delete
// here is ignored by BIND and would only append to the live set.
if strings.EqualFold(rec.Type, "NS") && fqdn(rec.Name, zone) == fqdn("@", zone) {
continue
}
ttl := defaultTTL
if rec.TTL != nil {
ttl = *rec.TTL
@@ -114,3 +119,119 @@ func alsoNotifyList(addrs []string, key string) string {
}
return strings.Join(parts, " ")
}
// absolute qualifies a nameserver name. Unlike record owner names, a
// spec.nameservers entry is always a full domain name, never relative to the
// zone: an in-zone nameserver is spelled out in full.
func absolute(name string) string { return strings.TrimSuffix(name, ".") + "." }
// zoneNameservers resolves the names to publish in a zone's apex NS RRset, the
// TTL to publish them with, and whether the zone declared them. An apex NS in
// spec.records counts as a declaration: BIND ignores an RRset-wide delete at the
// apex, so records alone can only append to what the zone was seeded with, never
// replace it. Undeclared zones fall back to the primary's stable in-cluster name,
// which is deliberately out-of-zone so no pod IP is needed as glue.
func zoneNameservers(zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (names []string, ttl int32, declared bool) {
ttl = zone.Spec.DefaultTTL
for _, ns := range zone.Spec.Nameservers {
names = append(names, absolute(ns))
}
for _, rec := range zone.Spec.Records {
if len(names) > 0 {
break
}
if !strings.EqualFold(rec.Type, "NS") || fqdn(rec.Name, zone.Spec.ZoneName) != fqdn("@", zone.Spec.ZoneName) {
continue
}
for _, v := range rec.Values {
names = append(names, absolute(v))
}
if rec.TTL != nil {
ttl = *rec.TTL
}
}
if ttl <= 0 {
ttl = 3600
}
if len(names) > 0 {
return names, ttl, true
}
return clusterNameservers(cluster), ttl, false
}
// apexNSUpdates moves a zone's apex NS RRset from live onto desired, and retires
// the glue of any in-zone name it retracts. Adds come first: BIND refuses to
// leave an apex with no NS record, so the replacement must exist before the old
// name goes, and deleting glue still referenced by an in-zone NS fails named's
// post-update nameserver sanity check.
//
// An empty live set means the query could not see the zone, not that the apex has
// no NS records — a primary always has one. Nothing is retracted in that case:
// retracting blind is what turns a delete into "delete the last NS", which named
// rejects outright.
// ponytail: names already published are diffed by name only, so an edit to just
// the TTL never republishes them (dig +short cannot report a TTL). Re-add the
// whole desired set each pass if TTL edits need to converge.
func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, live []string, ttl int32) []bind.RecordUpdate {
apex := fqdn("@", zone.Spec.ZoneName)
add := missing(desired, live)
var updates []bind.RecordUpdate
if len(add) > 0 {
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", TTL: ttl, Values: add, PerValue: true})
}
// missing() yields nothing against an empty live set, so an unreadable RRset
// retracts nothing on its own.
del := missing(live, desired)
if len(del) == 0 {
return updates
}
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", Values: del, PerValue: true, Delete: true})
// The seed glues an in-zone nameserver to the primary pod's IP, which goes
// stale on the first reschedule. Drop that address with the name, unless
// spec.records owns it (then it is real data, not the placeholder).
for _, ns := range del {
owner, in := bind.InZoneOwner(ns, zone.Spec.ZoneName)
if in && owner != "@" && !recordsOwn(zone, owner, "A") {
updates = append(updates, bind.RecordUpdate{FQDN: fqdn(owner, zone.Spec.ZoneName), Type: "A", Delete: true})
}
}
return updates
}
// clusterNameservers is the apex NS for the operator's own internal zones
// (catalog, policy): the primary's stable in-cluster name, never a pod IP.
func clusterNameservers(cluster *bindv1alpha1.BindCluster) []string {
return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."}
}
// missing returns the names in want with no match in have. DNS names compare
// case-insensitively.
func missing(want, have []string) (out []string) {
for _, w := range want {
if !containsName(have, w) {
out = append(out, w)
}
}
return out
}
func containsName(names []string, name string) bool {
for _, n := range names {
if strings.EqualFold(absolute(n), absolute(name)) {
return true
}
}
return false
}
// recordsOwn reports whether spec.records already manages an owner/type pair, in
// which case the apex sync must leave it alone.
func recordsOwn(zone *bindv1alpha1.BindZone, name, typ string) bool {
for _, rec := range zone.Spec.Records {
if strings.EqualFold(rec.Type, typ) && strings.EqualFold(fqdn(rec.Name, zone.Spec.ZoneName), fqdn(name, zone.Spec.ZoneName)) {
return true
}
}
return false
}