Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 439aa9ea6b | |||
| 6a07f91ea1 | |||
| e4ed9cfdb2 | |||
| 9c81320df8 | |||
| 243e776b59 | |||
| 59612f157a | |||
| 8fac152537 | |||
| 2deea3e023 |
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
|
|||||||
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
||||||
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
||||||
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
||||||
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). |
|
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
|
||||||
| `BindACL` | A reusable named `address_match_list`. |
|
| `BindACL` | A reusable named `address_match_list`. |
|
||||||
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
||||||
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
|
|||||||
// `secret` key and the operator will not generate new material.
|
// `secret` key and the operator will not generate new material.
|
||||||
// +optional
|
// +optional
|
||||||
ImportExisting bool `json:"importExisting,omitempty"`
|
ImportExisting bool `json:"importExisting,omitempty"`
|
||||||
|
|
||||||
|
// SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
// Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
// another namespace. Operator-managed labels are always preserved.
|
||||||
|
// +optional
|
||||||
|
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretMetadata carries extra labels and annotations to stamp onto a
|
||||||
|
// Secret managed by the operator.
|
||||||
|
type SecretMetadata struct {
|
||||||
|
// Annotations to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
|
||||||
|
// Labels to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindTSIGKeyStatus reports observed TSIG key state.
|
// BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.SecretTemplate != nil {
|
||||||
|
in, out := &in.SecretTemplate, &out.SecretTemplate
|
||||||
|
*out = new(SecretMetadata)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
||||||
@@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
|
|||||||
in.DeepCopyInto(out)
|
in.DeepCopyInto(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
|
||||||
|
*out = *in
|
||||||
|
if in.Annotations != nil {
|
||||||
|
in, out := &in.Annotations, &out.Annotations
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Labels != nil {
|
||||||
|
in, out := &in.Labels, &out.Labels
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
|
||||||
|
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(SecretMetadata)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -2376,6 +2376,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ spec:
|
|||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
---
|
---
|
||||||
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
||||||
# dynamic updates to the dynamic cluster's primary.
|
# dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
|
||||||
|
# generated Secret into the external-dns namespace via emberstack reflector, so
|
||||||
|
# external-dns presents exactly the key the primary's allow-update accepts.
|
||||||
apiVersion: bind.unkin.net/v1alpha1
|
apiVersion: bind.unkin.net/v1alpha1
|
||||||
kind: BindTSIGKey
|
kind: BindTSIGKey
|
||||||
metadata:
|
metadata:
|
||||||
@@ -19,3 +21,9 @@ metadata:
|
|||||||
namespace: bind-externaldns
|
namespace: bind-externaldns
|
||||||
spec:
|
spec:
|
||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
|
secretTemplate:
|
||||||
|
annotations:
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-logr/logr v1.4.2
|
||||||
k8s.io/api v0.34.4
|
k8s.io/api v0.34.4
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
@@ -17,7 +18,6 @@ require (
|
|||||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
|
||||||
github.com/go-logr/zapr v1.3.0 // indirect
|
github.com/go-logr/zapr v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
|||||||
@@ -28,9 +28,28 @@ type RenderInput struct {
|
|||||||
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
|
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
|
||||||
// one based on the pod ordinal.
|
// one based on the pod ordinal.
|
||||||
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
|
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
|
||||||
|
// client.List returns cache-ordered (non-deterministic) results, so sort
|
||||||
|
// every input slice before rendering. Otherwise the rendered config
|
||||||
|
// reshuffles between reconciles, churning the ConfigMap — and with the
|
||||||
|
// pod-template config hash that means an endless rolling restart.
|
||||||
|
sortInput(&in)
|
||||||
return render(in, true), render(in, false)
|
return render(in, true), render(in, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortInput orders every list rendered into named.conf deterministically.
|
||||||
|
func sortInput(in *RenderInput) {
|
||||||
|
sort.Slice(in.ACLs, func(i, j int) bool { return in.ACLs[i].Name < in.ACLs[j].Name })
|
||||||
|
sort.Slice(in.Views, func(i, j int) bool {
|
||||||
|
if in.Views[i].Spec.Order != in.Views[j].Spec.Order {
|
||||||
|
return in.Views[i].Spec.Order < in.Views[j].Spec.Order
|
||||||
|
}
|
||||||
|
return in.Views[i].Name < in.Views[j].Name
|
||||||
|
})
|
||||||
|
sort.Slice(in.Forwards, func(i, j int) bool { return in.Forwards[i].Spec.ZoneName < in.Forwards[j].Spec.ZoneName })
|
||||||
|
sort.Slice(in.Policies, func(i, j int) bool { return in.Policies[i].Spec.ZoneName < in.Policies[j].Spec.ZoneName })
|
||||||
|
sort.Slice(in.DNSSECPolicies, func(i, j int) bool { return in.DNSSECPolicies[i].Name < in.DNSSECPolicies[j].Name })
|
||||||
|
}
|
||||||
|
|
||||||
func render(in RenderInput, isPrimary bool) string {
|
func render(in RenderInput, isPrimary bool) string {
|
||||||
c := in.Cluster
|
c := in.Cluster
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -146,3 +146,34 @@ func TestCatalogHashStable(t *testing.T) {
|
|||||||
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
|
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDeterministicWithShuffledForwards(t *testing.T) {
|
||||||
|
// client.List order is non-deterministic; the render must not depend on
|
||||||
|
// input order, or the ConfigMap churns and (with the config hash) the
|
||||||
|
// StatefulSet rolls forever.
|
||||||
|
mkFwd := func(zone, fwd string) bindv1alpha1.BindZone {
|
||||||
|
return bindv1alpha1.BindZone{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: zone},
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{
|
||||||
|
ClusterRef: "r", Type: bindv1alpha1.ZoneForward,
|
||||||
|
ZoneName: zone, Forwarders: []string{fwd},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}
|
||||||
|
orderA := base
|
||||||
|
orderA.Forwards = []bindv1alpha1.BindZone{
|
||||||
|
mkFwd("unkin.net", "198.18.200.6"), mkFwd("consul", "198.18.19.14"),
|
||||||
|
mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"), mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"),
|
||||||
|
}
|
||||||
|
orderB := base
|
||||||
|
orderB.Forwards = []bindv1alpha1.BindZone{
|
||||||
|
mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"), mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"),
|
||||||
|
mkFwd("consul", "198.18.19.14"), mkFwd("unkin.net", "198.18.200.6"),
|
||||||
|
}
|
||||||
|
pa, _ := RenderNamedConf(orderA)
|
||||||
|
pb, _ := RenderNamedConf(orderB)
|
||||||
|
if pa != pb {
|
||||||
|
t.Fatalf("render must be independent of forward-zone input order:\n--- A ---\n%s\n--- B ---\n%s", pa, pb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,13 +35,19 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
|
|||||||
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
|
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
|
||||||
origin := dot(zone)
|
origin := dot(zone)
|
||||||
ns := "ns1." + origin
|
ns := "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
|
content := fmt.Sprintf(`$TTL 3600
|
||||||
@ IN SOA %s hostmaster.%s (
|
@ IN SOA %s hostmaster.%s (
|
||||||
%d ; serial
|
%d ; serial
|
||||||
3600 ; refresh
|
300 ; refresh
|
||||||
900 ; retry
|
60 ; retry
|
||||||
1209600 ; expire
|
1209600 ; expire
|
||||||
300 ) ; minimum
|
60 ) ; minimum
|
||||||
@ IN NS %s
|
@ IN NS %s
|
||||||
ns1 IN A %s
|
ns1 IN A %s
|
||||||
`, ns, origin, serial, ns, primaryIP)
|
`, ns, origin, serial, ns, primaryIP)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -351,6 +354,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pods copy config from the projected volume into an emptyDir once at
|
||||||
|
// startup; a ConfigMap/keys change never reaches a running pod (rndc
|
||||||
|
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
|
||||||
|
// config onto the pod template so a config change rolls the StatefulSet,
|
||||||
|
// which is the only way the change takes effect. The operator owns the
|
||||||
|
// template, so this restart is operator-driven and not reverted.
|
||||||
sts := &appsv1.StatefulSet{
|
sts := &appsv1.StatefulSet{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
|
||||||
Spec: appsv1.StatefulSetSpec{
|
Spec: appsv1.StatefulSetSpec{
|
||||||
@@ -358,7 +367,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
Replicas: &replicas,
|
Replicas: &replicas,
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: labels},
|
Selector: &metav1.LabelSelector{MatchLabels: labels},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
NodeSelector: c.Spec.NodeSelector,
|
NodeSelector: c.Spec.NodeSelector,
|
||||||
Tolerations: c.Spec.Tolerations,
|
Tolerations: c.Spec.Tolerations,
|
||||||
@@ -426,6 +435,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
return &existing, nil
|
return &existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configHashAnnotation carries a hash of the projected config on the pod
|
||||||
|
// template; changing it triggers a rolling restart so pods pick up new config.
|
||||||
|
const configHashAnnotation = "bind.unkin.net/config-hash"
|
||||||
|
|
||||||
|
// configHash returns a deterministic hash of the config projected into the pods
|
||||||
|
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
|
||||||
|
// reconciled, so it reflects the current desired config. A stable hash means no
|
||||||
|
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
|
||||||
|
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var cm corev1.ConfigMap
|
||||||
|
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
|
||||||
|
for _, k := range sortedKeys(cm.Data) {
|
||||||
|
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var keys corev1.Secret
|
||||||
|
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
|
||||||
|
data := make(map[string]string, len(keys.Data))
|
||||||
|
for k, v := range keys.Data {
|
||||||
|
data[k] = string(v)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(data) {
|
||||||
|
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(buf.Bytes())
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
|
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
|
||||||
if r.Exec == nil {
|
if r.Exec == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, genErr
|
return ctrl.Result{}, genErr
|
||||||
}
|
}
|
||||||
newSecret := &corev1.Secret{
|
newSecret := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
|
||||||
Data: map[string][]byte{
|
Data: map[string][]byte{
|
||||||
"algorithm": []byte(algorithm),
|
"algorithm": []byte(algorithm),
|
||||||
"keyName": []byte(keyName),
|
"keyName": []byte(keyName),
|
||||||
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
|
||||||
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
|
default:
|
||||||
|
// Secret already exists: reconcile the template-managed metadata so that
|
||||||
|
// annotation/label changes on the CR (e.g. reflection hints) propagate
|
||||||
|
// without regenerating key material. Skip imported secrets, which are
|
||||||
|
// owned by an external manager (Vault/VSO, reflector) that we must not
|
||||||
|
// fight over metadata.
|
||||||
|
if key.Spec.ImportExisting {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
|
||||||
|
if err := r.Update(ctx, &secret); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key.Status.SecretName = secretName
|
key.Status.SecretName = secretName
|
||||||
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySecretTemplate stamps the operator-managed label plus any
|
||||||
|
// user-supplied labels/annotations onto the Secret's metadata. It returns true
|
||||||
|
// if it mutated meta, so callers can decide whether an update is needed.
|
||||||
|
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
|
||||||
|
changed := false
|
||||||
|
setLabel := func(k, v string) {
|
||||||
|
if meta.Labels == nil {
|
||||||
|
meta.Labels = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Labels[k] != v {
|
||||||
|
meta.Labels[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnnotation := func(k, v string) {
|
||||||
|
if meta.Annotations == nil {
|
||||||
|
meta.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Annotations[k] != v {
|
||||||
|
meta.Annotations[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabel(managedByLabel, managedByValue)
|
||||||
|
if tmpl != nil {
|
||||||
|
for k, v := range tmpl.Labels {
|
||||||
|
setLabel(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range tmpl.Annotations {
|
||||||
|
setAnnotation(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
||||||
key.Status.Ready = false
|
key.Status.Ready = false
|
||||||
key.Status.ObservedGeneration = key.Generation
|
key.Status.ObservedGeneration = key.Generation
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplySecretTemplate(t *testing.T) {
|
||||||
|
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
|
||||||
|
var meta metav1.ObjectMeta
|
||||||
|
if !applySecretTemplate(&meta, nil) {
|
||||||
|
t.Fatal("expected change on empty meta")
|
||||||
|
}
|
||||||
|
if meta.Labels[managedByLabel] != managedByValue {
|
||||||
|
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
|
||||||
|
}
|
||||||
|
if meta.Annotations != nil {
|
||||||
|
t.Errorf("annotations = %v, want nil", meta.Annotations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("applies labels and annotations", func(t *testing.T) {
|
||||||
|
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{
|
||||||
|
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
|
||||||
|
Labels: map[string]string{"team": "dns"},
|
||||||
|
}
|
||||||
|
if !applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Fatal("expected change when adding template metadata")
|
||||||
|
}
|
||||||
|
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
|
||||||
|
t.Errorf("reflection annotation = %q, want true", got)
|
||||||
|
}
|
||||||
|
if meta.Labels["team"] != "dns" {
|
||||||
|
t.Errorf("team label = %q, want dns", meta.Labels["team"])
|
||||||
|
}
|
||||||
|
// managed-by must survive user-supplied labels.
|
||||||
|
if meta.Labels[managedByLabel] != managedByValue {
|
||||||
|
t.Errorf("managed-by label dropped: %v", meta.Labels)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotent when already applied", func(t *testing.T) {
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{
|
||||||
|
Annotations: map[string]string{"a": "1"},
|
||||||
|
Labels: map[string]string{"b": "2"},
|
||||||
|
}
|
||||||
|
meta := metav1.ObjectMeta{}
|
||||||
|
applySecretTemplate(&meta, tmpl)
|
||||||
|
if applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Error("expected no change on second apply")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates drifted annotation value", func(t *testing.T) {
|
||||||
|
meta := metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{managedByLabel: managedByValue},
|
||||||
|
Annotations: map[string]string{"a": "old"},
|
||||||
|
}
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
|
||||||
|
if !applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Fatal("expected change when annotation value drifts")
|
||||||
|
}
|
||||||
|
if meta.Annotations["a"] != "new" {
|
||||||
|
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -80,7 +80,15 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster))
|
// Primary zones replicated to secondaries (catalog members) get an
|
||||||
|
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
|
||||||
|
// them immediately rather than waiting for the SOA refresh.
|
||||||
|
var notifyTargets []string
|
||||||
|
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
|
||||||
|
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster), notifyTargets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
||||||
}
|
}
|
||||||
@@ -135,7 +143,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
||||||
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
|
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
|
||||||
// primary zones must allow AXFR with it so secondaries can pull them.
|
// primary zones must allow AXFR with it so secondaries can pull them.
|
||||||
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string) (string, error) {
|
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string) (string, error) {
|
||||||
zType := zone.Spec.Type
|
zType := zone.Spec.Type
|
||||||
if zType == "" {
|
if zType == "" {
|
||||||
zType = bindv1alpha1.ZonePrimary
|
zType = bindv1alpha1.ZonePrimary
|
||||||
@@ -154,6 +162,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
|
|||||||
// Catalog member: permit key-authenticated AXFR from secondaries.
|
// Catalog member: permit key-authenticated AXFR from secondaries.
|
||||||
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
|
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
|
||||||
}
|
}
|
||||||
|
// NOTIFY only the secondaries we know about (their apex NS is the primary
|
||||||
|
// itself, so default `notify yes` would reach no one). `notify explicit`
|
||||||
|
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs.
|
||||||
|
if len(notifyTargets) > 0 {
|
||||||
|
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", terminateInline(notifyTargets)))
|
||||||
|
}
|
||||||
if zone.Spec.DNSSECPolicyRef != "" {
|
if zone.Spec.DNSSECPolicyRef != "" {
|
||||||
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A primary zone with known secondaries renders notify explicit + also-notify
|
||||||
|
// so a dynamic update NOTIFYs the secondaries immediately.
|
||||||
|
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
|
||||||
|
r := &BindZoneReconciler{}
|
||||||
|
zone := &bindv1alpha1.BindZone{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{
|
||||||
|
ZoneName: "main.unkin.net",
|
||||||
|
Type: bindv1alpha1.ZonePrimary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildZoneConfig: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "notify explicit") {
|
||||||
|
t.Errorf("expected notify explicit in %q", cfg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "also-notify { 10.42.2.6; 10.42.1.5; }") {
|
||||||
|
t.Errorf("expected also-notify with the secondary IPs in %q", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no secondaries, no also-notify is emitted (single-replica cluster).
|
||||||
|
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
|
||||||
|
r := &BindZoneReconciler{}
|
||||||
|
zone := &bindv1alpha1.BindZone{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildZoneConfig: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
|
||||||
|
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigHashStableAndSensitive(t *testing.T) {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
|
||||||
|
cm := &corev1.ConfigMap{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: configMapName("c"), Namespace: "ns"},
|
||||||
|
Data: map[string]string{"named.conf.secondary": "options { recursion yes; };"},
|
||||||
|
}
|
||||||
|
keys := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: keysSecretName("c"), Namespace: "ns"},
|
||||||
|
Data: map[string][]byte{"keys.conf": []byte("key x {};")},
|
||||||
|
}
|
||||||
|
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, keys).Build()
|
||||||
|
r := &BindClusterReconciler{Client: c, Scheme: scheme}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h1 := r.configHash(ctx, cluster)
|
||||||
|
if h1 == "" {
|
||||||
|
t.Fatal("hash should not be empty when config exists")
|
||||||
|
}
|
||||||
|
// Stable across calls when nothing changes.
|
||||||
|
if h2 := r.configHash(ctx, cluster); h2 != h1 {
|
||||||
|
t.Fatalf("hash not stable: %s != %s", h1, h2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config change flips the hash (this is what rolls the StatefulSet).
|
||||||
|
cm.Data["named.conf.secondary"] = "options { recursion yes; validate-except { unkin.net; }; };"
|
||||||
|
if err := c.Update(ctx, cm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h3 := r.configHash(ctx, cluster); h3 == h1 {
|
||||||
|
t.Fatal("hash must change when the ConfigMap changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A TSIG key (keys.conf) change also flips it.
|
||||||
|
afterCM := r.configHash(ctx, cluster)
|
||||||
|
keys.Data["keys.conf"] = []byte("key x { algorithm hmac-sha256; };")
|
||||||
|
if err := c.Update(ctx, keys); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h4 := r.configHash(ctx, cluster); h4 == afterCM {
|
||||||
|
t.Fatal("hash must change when keys.conf changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
@@ -126,6 +127,34 @@ func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv
|
|||||||
return primaryPodIP(ctx, c, cluster)
|
return primaryPodIP(ctx, c, cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
|
||||||
|
// except the ordinal-0 primary) that currently have an address. The primary
|
||||||
|
// uses this list as its zone `also-notify` set, so a change to a primary zone
|
||||||
|
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
|
||||||
|
// secondaries instead of leaving them stale until the next SOA refresh. The
|
||||||
|
// list is sorted so the rendered zone config is stable and does not churn
|
||||||
|
// modzone on every reconcile. Pod IPs change across restarts, so the caller
|
||||||
|
// relies on the zone controller's periodic requeue to refresh the set (a
|
||||||
|
// restarted secondary re-transfers the whole zone on load regardless). Returns
|
||||||
|
// nil for a single-replica cluster.
|
||||||
|
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
|
||||||
|
var pods corev1.PodList
|
||||||
|
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
primary := primaryPodName(cluster.Name)
|
||||||
|
var ips []string
|
||||||
|
for i := range pods.Items {
|
||||||
|
p := &pods.Items[i]
|
||||||
|
if p.Name == primary || p.Status.PodIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, p.Status.PodIP)
|
||||||
|
}
|
||||||
|
sort.Strings(ips)
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
|
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
|
||||||
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
|
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
|
||||||
var creds bind.TSIGCreds
|
var creds bind.TSIGCreds
|
||||||
|
|||||||
Reference in New Issue
Block a user