aab11457af
v0.2.5 (PR #14) added an options-scope allow-notify enumerating the primary pod IP on secondaries. Options-scope config feeds the config-hash annotation that rolls the StatefulSet, so any config change rolled the pods, the primary came back on a new pod IP, the operator re-rendered with the new IP, the hash changed, the pods rolled again — an infinite roll loop across every BindCluster. The prod deployment was reverted to v0.2.4. Replace the pod-IP allow-notify with TSIG-authenticated NOTIFY: - Secondaries render `allow-notify { key "<name>"; };` — a static key element with NO IPs. It depends only on the key name, so pod-IP churn can never change the render, the config-hash, or trigger a restart. - The primary signs its outgoing NOTIFYs: the zone-scope also-notify entries (already enumerating replica pod IPs, applied via rndc addzone/modzone with NO restart) now carry `key "<name>"`. - Key choice: reuse the cluster's catalog transfer TSIG key (TransferKeyRef). Secondaries already present it for AXFR and it is in keys.conf on every pod, so no new key plumbing is needed. Add a permanent regression guard for the loop class: - controller: reconcile the ConfigMap with the primary pod on two different IPs and assert the config-hash is byte-identical. - render: render restart-scoped input and assert no pod IP appears in allow-notify; RenderInput no longer has any pod-IP field. Zone-scope also-notify (rndc, no restart) legitimately still lists pod IPs; only restart-scoped config must be pod-IP-independent.
117 lines
3.2 KiB
Go
117 lines
3.2 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
|
"git.unkin.net/unkin/bind-operator/internal/bind"
|
|
)
|
|
|
|
func isPrimaryType(t bindv1alpha1.ZoneType) bool {
|
|
return t == bindv1alpha1.ZonePrimary || t == ""
|
|
}
|
|
|
|
// catalogEnabled reports whether a primary zone should be registered in the
|
|
// cluster catalog zone.
|
|
func catalogEnabled(zone *bindv1alpha1.BindZone) bool {
|
|
if !isPrimaryType(zone.Spec.Type) {
|
|
return false
|
|
}
|
|
if zone.Spec.Catalog == nil {
|
|
return true
|
|
}
|
|
return *zone.Spec.Catalog
|
|
}
|
|
|
|
// fqdn resolves a record owner name relative to a zone origin.
|
|
func fqdn(name, zone string) string {
|
|
zone = strings.TrimSuffix(zone, ".") + "."
|
|
if name == "" || name == "@" {
|
|
return zone
|
|
}
|
|
if strings.HasSuffix(name, ".") {
|
|
return name
|
|
}
|
|
return name + "." + zone
|
|
}
|
|
|
|
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
|
|
updates := make([]bind.RecordUpdate, 0, len(records))
|
|
for _, rec := range records {
|
|
ttl := defaultTTL
|
|
if rec.TTL != nil {
|
|
ttl = *rec.TTL
|
|
}
|
|
updates = append(updates, bind.RecordUpdate{
|
|
FQDN: fqdn(rec.Name, zone),
|
|
Type: rec.Type,
|
|
TTL: ttl,
|
|
Values: rec.Values,
|
|
})
|
|
}
|
|
return updates
|
|
}
|
|
|
|
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
|
|
// update key, falling back to the object name.
|
|
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
|
|
return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
|
|
}
|
|
|
|
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
|
|
// in named.conf (the KeyName override when set, otherwise the object name).
|
|
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
|
|
// read.
|
|
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
|
|
if ref == "" {
|
|
return ""
|
|
}
|
|
var key bindv1alpha1.BindTSIGKey
|
|
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
|
|
return ref
|
|
}
|
|
if key.Spec.KeyName != "" {
|
|
return key.Spec.KeyName
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// matchListInline renders address-match-list entries on one line.
|
|
func matchListInline(entries []string) string { return terminateInline(entries) }
|
|
|
|
func terminateInline(entries []string) string {
|
|
var parts []string
|
|
for _, e := range entries {
|
|
e = strings.TrimSpace(strings.TrimRight(e, ";"))
|
|
if e == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, e+";")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// alsoNotifyList renders also-notify entries, each optionally annotated with a
|
|
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
|
|
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
|
|
// carries a `key` clause is left untouched.
|
|
func alsoNotifyList(addrs []string, key string) string {
|
|
key = strings.TrimSpace(key)
|
|
var parts []string
|
|
for _, a := range addrs {
|
|
a = strings.TrimSpace(strings.TrimRight(a, ";"))
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if key != "" && !strings.Contains(a, " key ") {
|
|
a = fmt.Sprintf("%s key \"%s\"", a, key)
|
|
}
|
|
parts = append(parts, a+";")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|