Initial bind-operator: 9 CRDs + controllers

Implements a Kubernetes operator that manages fleets of BIND9 servers
declaratively, using controller-runtime (matching forgebot conventions).

- add BindCluster reconciler: StatefulSet (pod-0 primary, secondaries),
  headless + client Services, rendered named.conf ConfigMap, TSIG keys
  Secret and rndc control Secret; watches dependent CRs to re-render
- add BindTSIGKey reconciler that generates key material into a Secret
- add BindZone/DNSRecord reconcilers using fully-dynamic delivery
  (rndc addzone + TSIG nsupdate against the primary pod)
- add BindCatalogZone reconciler so secondaries auto-provision zones
- add BindPolicy (RPZ), BindDNSSECPolicy, BindView, BindACL reconcilers
- render primary/secondary named.conf variants selected by pod ordinal
- generate CRDs, deepcopy and RBAC; add samples mapping the three Puppet
  roles (authoritative/resolver/external-dns) to three BindClusters
- add Makefile, Dockerfile.operator, Woodpecker CI and kind manifests
This commit is contained in:
2026-07-03 15:48:13 +10:00
parent b3a5b4d0b7
commit fe5fbdaf6d
63 changed files with 8240 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
// Package bind contains helpers for driving BIND9 pods: executing rndc and
// nsupdate over the Kubernetes exec subresource, and rendering named.conf.
package bind
import (
"bytes"
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
// ContainerName is the BIND container name within each pod.
const ContainerName = "bind"
// Executor runs commands inside BIND pods via the exec subresource.
type Executor struct {
config *rest.Config
clientset kubernetes.Interface
}
// NewExecutor builds an Executor from a controller-runtime rest config.
func NewExecutor(cfg *rest.Config) (*Executor, error) {
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("build clientset: %w", err)
}
return &Executor{config: cfg, clientset: cs}, nil
}
// Exec runs command in the BIND container of pod, optionally feeding stdin, and
// returns stdout. A non-zero exit or transport error yields an error that
// includes stderr.
func (e *Executor) Exec(ctx context.Context, namespace, pod string, command []string, stdin string) (string, error) {
req := e.clientset.CoreV1().RESTClient().Post().
Resource("pods").
Name(pod).
Namespace(namespace).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: ContainerName,
Command: command,
Stdin: stdin != "",
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(e.config, "POST", req.URL())
if err != nil {
return "", fmt.Errorf("spdy executor: %w", err)
}
var stdout, stderr bytes.Buffer
opts := remotecommand.StreamOptions{Stdout: &stdout, Stderr: &stderr}
if stdin != "" {
opts.Stdin = bytes.NewBufferString(stdin)
}
if err := exec.StreamWithContext(ctx, opts); err != nil {
return stdout.String(), fmt.Errorf("exec %v: %w (stderr: %s)", command, err, stderr.String())
}
return stdout.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package bind
import (
"crypto/sha1"
"encoding/hex"
"strings"
)
// catalogHash returns the unique member label for a catalog zone entry: the
// hex-encoded SHA-1 digest of the member zone name in DNS wire format, per the
// BIND catalog-zone schema (RFC 9432).
func catalogHash(zone string) string {
sum := sha1.Sum(wireName(zone))
return hex.EncodeToString(sum[:])
}
// wireName encodes a domain name into uncompressed DNS wire format: each label
// length-prefixed, terminated by a zero-length root label. Names are lowercased.
func wireName(name string) []byte {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
var out []byte
if name != "" {
for _, label := range strings.Split(name, ".") {
out = append(out, byte(len(label)))
out = append(out, []byte(label)...)
}
}
return append(out, 0)
}
+34
View File
@@ -0,0 +1,34 @@
package bind
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateSecret returns a base64-encoded cryptographically-random key of n
// bytes, suitable for a TSIG or rndc HMAC secret.
func GenerateSecret(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.StdEncoding.EncodeToString(buf), nil
}
// KeyClause renders a named.conf `key` block for inclusion.
func KeyClause(name, algorithm, secret string) string {
return fmt.Sprintf("key \"%s\" {\n algorithm %s;\n secret \"%s\";\n};\n", name, algorithm, secret)
}
// SecretBytesForAlgorithm returns a reasonable key length for a TSIG algorithm.
func SecretBytesForAlgorithm(algorithm string) int {
switch algorithm {
case "hmac-sha512", "hmac-sha384":
return 64
case "hmac-sha256":
return 32
default:
return 32
}
}
+60
View File
@@ -0,0 +1,60 @@
package bind
import (
"context"
"fmt"
"strings"
)
// TSIGCreds carries the material needed to authenticate a dynamic update.
type TSIGCreds struct {
Name string // TSIG key name
Algorithm string // e.g. hmac-sha256
Secret string // base64-encoded key
}
// RecordUpdate describes a desired record set to apply to a zone.
type RecordUpdate struct {
FQDN string // fully-qualified owner name, trailing dot recommended
Type string // RR type
TTL int32 // record TTL
Values []string // RDATA entries
Delete bool // when true, delete the RRset instead of replacing it
}
// 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{"nsupdate", "-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 {
return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// dot ensures a name is fully qualified with a trailing dot.
func dot(name string) string {
if name == "" || name == "@" {
return "@"
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "."
}
+289
View File
@@ -0,0 +1,289 @@
package bind
import (
"fmt"
"sort"
"strings"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// RenderInput aggregates everything needed to render a cluster's named.conf.
type RenderInput struct {
Cluster *bindv1alpha1.BindCluster
ACLs []bindv1alpha1.BindACL
Views []bindv1alpha1.BindView
Policies []bindv1alpha1.BindPolicy
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
Catalog *bindv1alpha1.BindCatalogZone
// PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string
}
// DataDir is where BIND keeps zone databases and journals (backed by the PVC).
const DataDir = "/var/lib/named"
// RenderNamedConf returns the primary and secondary named.conf contents for a
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
// one based on the pod ordinal.
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
return render(in, true), render(in, false)
}
func render(in RenderInput, isPrimary bool) string {
c := in.Cluster
var b strings.Builder
b.WriteString("// Managed by bind-operator. Do not edit.\n")
b.WriteString(`include "/etc/bind/keys/keys.conf";` + "\n\n")
// Named ACLs (global scope).
acls := append([]bindv1alpha1.BindACL(nil), in.ACLs...)
sort.Slice(acls, func(i, j int) bool { return acls[i].Name < acls[j].Name })
for _, a := range acls {
b.WriteString(fmt.Sprintf("acl \"%s\" { %s };\n", a.Name, matchList(a.Spec.Entries)))
}
if len(acls) > 0 {
b.WriteString("\n")
}
// DNSSEC policies (must precede zones that reference them).
for _, p := range in.DNSSECPolicies {
b.WriteString(renderDNSSECPolicy(p))
}
// options.
b.WriteString("options {\n")
b.WriteString(fmt.Sprintf(" directory \"%s\";\n", DataDir))
b.WriteString(" listen-on port 53 { any; };\n")
b.WriteString(" listen-on-v6 port 53 { any; };\n")
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(recursionFor(c))))
if len(c.Spec.Forwarders) > 0 {
b.WriteString(fmt.Sprintf(" forwarders { %s };\n", terminate(c.Spec.Forwarders)))
}
if allowNewZones(c) {
b.WriteString(" allow-new-zones yes;\n")
}
b.WriteString(" dnssec-validation auto;\n")
for _, o := range c.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// When there are no views, response-policy and catalog-zones live in options.
if len(in.Views) == 0 {
b.WriteString(responsePolicyClause(in.Policies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
}
b.WriteString("};\n\n")
// controls (rndc).
b.WriteString("controls {\n")
b.WriteString(" inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { \"rndc-key\"; };\n")
b.WriteString("};\n\n")
// Views, if any.
views := append([]bindv1alpha1.BindView(nil), in.Views...)
sort.Slice(views, func(i, j int) bool { return views[i].Spec.Order < views[j].Spec.Order })
for _, v := range views {
b.WriteString(renderView(v, in, isPrimary))
}
// Catalog zone declaration lives at top level when there are no views.
if in.Catalog != nil && len(in.Views) == 0 {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
}
return b.String()
}
func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("view \"%s\" {\n", v.Name))
mc := v.Spec.MatchClients
if len(mc) == 0 {
mc = []string{"any"}
}
b.WriteString(fmt.Sprintf(" match-clients { %s };\n", matchList(mc)))
if len(v.Spec.MatchDestinations) > 0 {
b.WriteString(fmt.Sprintf(" match-destinations { %s };\n", matchList(v.Spec.MatchDestinations)))
}
rec := recursionFor(in.Cluster)
if v.Spec.Recursion != nil {
rec = *v.Spec.Recursion
}
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(rec)))
if len(v.Spec.AllowQuery) > 0 {
b.WriteString(fmt.Sprintf(" allow-query { %s };\n", matchList(v.Spec.AllowQuery)))
}
for _, o := range v.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// Policies and catalog scoped to this view.
viewPolicies := filterPoliciesForView(in.Policies, v.Name)
b.WriteString(responsePolicyClause(viewPolicies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
if in.Catalog != nil {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
}
b.WriteString("};\n\n")
return b.String()
}
func renderDNSSECPolicy(p bindv1alpha1.BindDNSSECPolicy) string {
name := p.Spec.PolicyName
if name == "" {
name = p.Name
}
var b strings.Builder
b.WriteString(fmt.Sprintf("dnssec-policy \"%s\" {\n", name))
if p.Spec.NSEC3 {
b.WriteString(" nsec3param;\n")
}
if p.Spec.MaxZoneTTL != "" {
b.WriteString(fmt.Sprintf(" max-zone-ttl %s;\n", p.Spec.MaxZoneTTL))
}
if p.Spec.SignaturesValidity != "" {
b.WriteString(fmt.Sprintf(" signatures-validity %s;\n", p.Spec.SignaturesValidity))
}
alg := p.Spec.Algorithm
if alg == "" {
alg = "ecdsap256sha256"
}
if p.Spec.CSK != nil {
b.WriteString(" keys {\n")
b.WriteString(" csk " + keyLine(p.Spec.CSK, alg) + ";\n")
b.WriteString(" };\n")
} else {
b.WriteString(" keys {\n")
if p.Spec.KSK != nil {
b.WriteString(" ksk " + keyLine(p.Spec.KSK, alg) + ";\n")
}
if p.Spec.ZSK != nil {
b.WriteString(" zsk " + keyLine(p.Spec.ZSK, alg) + ";\n")
}
b.WriteString(" };\n")
}
for _, o := range p.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
b.WriteString("};\n\n")
return b.String()
}
func keyLine(k *bindv1alpha1.DNSSECKey, defaultAlg string) string {
lifetime := k.Lifetime
if lifetime == "" {
lifetime = "unlimited"
}
alg := k.Algorithm
if alg == "" {
alg = defaultAlg
}
if k.KeySize > 0 {
return fmt.Sprintf("lifetime %s algorithm %s %d", lifetime, alg, k.KeySize)
}
return fmt.Sprintf("lifetime %s algorithm %s", lifetime, alg)
}
func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) string {
if len(policies) == 0 {
return ""
}
sorted := append([]bindv1alpha1.BindPolicy(nil), policies...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Spec.Order < sorted[j].Spec.Order })
var b strings.Builder
b.WriteString(indent + "response-policy {\n")
for _, p := range sorted {
b.WriteString(fmt.Sprintf("%s zone \"%s\";\n", indent, p.Spec.ZoneName))
}
b.WriteString(indent + "};\n")
return b.String()
}
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
// Only secondaries consume the catalog to auto-provision member zones.
if in.Catalog == nil || isPrimary {
return ""
}
primaries := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
var b strings.Builder
b.WriteString(indent + "catalog-zones {\n")
b.WriteString(fmt.Sprintf("%s zone \"%s\" default-primaries { %s };\n", indent, in.Catalog.Spec.ZoneName, terminate(primaries)))
b.WriteString(indent + "};\n")
return b.String()
}
// renderCatalogZoneDecl declares the catalog zone as a secondary on consumer
// pods. The primary hosts the catalog zone dynamically (created by the
// BindCatalogZone controller via rndc addzone), so nothing is emitted here for
// the primary.
func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string {
if isPrimary {
return ""
}
cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName)
primaries := cat.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName))
b.WriteString(indent + " type secondary;\n")
b.WriteString(fmt.Sprintf("%s file \"%s\";\n", indent, file))
b.WriteString(fmt.Sprintf("%s primaries { %s };\n", indent, terminate(primaries)))
b.WriteString(indent + "};\n\n")
return b.String()
}
func filterPoliciesForView(policies []bindv1alpha1.BindPolicy, view string) []bindv1alpha1.BindPolicy {
var out []bindv1alpha1.BindPolicy
for _, p := range policies {
if p.Spec.ViewRef == view || p.Spec.ViewRef == "" {
out = append(out, p)
}
}
return out
}
// matchList renders address-match-list elements, each terminated with a
// semicolon: `10.0.0.0/8; key foo;`.
func matchList(entries []string) string {
return terminate(entries)
}
// terminate joins elements each followed by "; ".
func terminate(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, " ")
}
func yesno(b bool) string {
if b {
return "yes"
}
return "no"
}
func recursionFor(c *bindv1alpha1.BindCluster) bool {
if c.Spec.Recursion != nil {
return *c.Spec.Recursion
}
return c.Spec.Mode == bindv1alpha1.ModeResolver
}
func allowNewZones(c *bindv1alpha1.BindCluster) bool {
if c.Spec.AllowNewZones != nil {
return *c.Spec.AllowNewZones
}
return true
}
+80
View File
@@ -0,0 +1,80 @@
package bind
import (
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func newCluster(mode bindv1alpha1.BindMode) *bindv1alpha1.BindCluster {
return &bindv1alpha1.BindCluster{
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "dns"},
Spec: bindv1alpha1.BindClusterSpec{Mode: mode, Replicas: 3},
}
}
func TestRenderResolverEnablesRecursion(t *testing.T) {
primary, secondary := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)})
if !strings.Contains(primary, "recursion yes;") {
t.Fatalf("resolver primary should enable recursion:\n%s", primary)
}
if !strings.Contains(secondary, "recursion yes;") {
t.Fatalf("resolver secondary should enable recursion")
}
}
func TestRenderAuthoritativeDisablesRecursion(t *testing.T) {
primary, _ := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative)})
if !strings.Contains(primary, "recursion no;") {
t.Fatalf("authoritative should disable recursion:\n%s", primary)
}
if !strings.Contains(primary, "allow-new-zones yes;") {
t.Fatalf("authoritative should allow new zones for dynamic provisioning")
}
}
func TestRenderCatalogOnSecondaryOnly(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", DefaultPrimaries: []string{"10.0.0.1"}}},
PrimaryAddress: "auth-0.auth-headless.dns.svc.cluster.local",
}
primary, secondary := RenderNamedConf(in)
if strings.Contains(primary, "catalog-zones") {
t.Fatalf("primary must not consume the catalog it publishes:\n%s", primary)
}
if !strings.Contains(secondary, "catalog-zones") {
t.Fatalf("secondary must consume the catalog zone:\n%s", secondary)
}
if !strings.Contains(secondary, "type secondary;") {
t.Fatalf("secondary must declare the catalog zone as a secondary")
}
}
func TestRenderACL(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
ACLs: []bindv1alpha1.BindACL{{
ObjectMeta: metav1.ObjectMeta{Name: "internal"},
Spec: bindv1alpha1.BindACLSpec{Entries: []string{"10.0.0.0/8", "192.168.0.0/16"}},
}},
}
primary, _ := RenderNamedConf(in)
if !strings.Contains(primary, `acl "internal" { 10.0.0.0/8; 192.168.0.0/16; };`) {
t.Fatalf("ACL not rendered correctly:\n%s", primary)
}
}
func TestCatalogHashStable(t *testing.T) {
// SHA-1 of the wire format of "example.com" is well-defined and stable.
h1 := catalogHash("example.com")
h2 := catalogHash("example.com.")
if h1 != h2 {
t.Fatalf("trailing dot should not change hash: %s vs %s", h1, h2)
}
if len(h1) != 40 {
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
}
}
+89
View File
@@ -0,0 +1,89 @@
package bind
import (
"context"
"fmt"
"strings"
)
// RndcConfPath is the operator-managed rndc client config mounted in each pod.
const RndcConfPath = "/etc/bind/rndc.conf"
// Rndc runs `rndc <args...>` on a pod and returns its output.
func (e *Executor) Rndc(ctx context.Context, namespace, pod string, args ...string) (string, error) {
base := []string{"rndc", "-c", RndcConfPath}
return e.Exec(ctx, namespace, pod, append(base, args...), "")
}
// Reconfig reloads named.conf and any newly added/removed zones without a full
// restart.
func (e *Executor) Reconfig(ctx context.Context, namespace, pod string) error {
_, err := e.Rndc(ctx, namespace, pod, "reconfig")
return err
}
// AddZone provisions a zone at runtime via `rndc addzone`. config is the inner
// zone clause, e.g. `{ type primary; file "db.example"; allow-update { key k; }; };`.
func (e *Executor) AddZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"addzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
// addzone fails if the zone already exists; fall back to modzone so the
// operation is idempotent.
if strings.Contains(err.Error(), "already exists") || strings.Contains(out, "already exists") {
return e.ModZone(ctx, namespace, pod, zone, view, config)
}
return err
}
return nil
}
// ModZone updates an existing runtime-added zone's configuration.
func (e *Executor) ModZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"modzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
_, err := e.Rndc(ctx, namespace, pod, args...)
return err
}
// DelZone removes a runtime-added zone. A missing zone is treated as success.
func (e *Executor) DelZone(ctx context.Context, namespace, pod, zone, view string) error {
args := []string{"delzone", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(out, "not found")) {
return nil
}
return err
}
// ZoneSerial returns the current SOA serial for a zone via `rndc zonestatus`.
func (e *Executor) ZoneSerial(ctx context.Context, namespace, pod, zone, view string) (int64, error) {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
return 0, err
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "serial:") {
var serial int64
if _, err := fmt.Sscanf(line, "serial: %d", &serial); err == nil {
return serial, nil
}
}
}
return 0, nil
}
+74
View File
@@ -0,0 +1,74 @@
package bind
import (
"context"
"fmt"
"strings"
)
// ZoneFilePath returns the on-pod path of a zone database file.
func ZoneFilePath(zone string) string {
return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// CatalogFilePath returns the on-pod path of a catalog zone database file.
func CatalogFilePath(zone string) string {
return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// ZoneExists reports whether a zone is currently loaded on the pod.
func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
_, err := e.Rndc(ctx, namespace, pod, args...)
return err == nil
}
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path,
// creating parent directories. It is only safe to call when creating a zone, as
// it overwrites any existing file.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error {
origin := dot(zone)
if primaryNS == "" {
primaryNS = "ns1." + origin
}
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
3600 ; refresh
900 ; retry
1209600 ; expire
300 ) ; minimum
@ IN NS %s
`, dot(primaryNS), origin, serial, dot(primaryNS))
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)}
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
}
// 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 {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{
FQDN: owner,
Type: "PTR",
TTL: 3600,
Values: []string{dot(memberZone)},
}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
// RemoveCatalogMember deregisters a member zone from a catalog zone.
func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
+41
View File
@@ -0,0 +1,41 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindACLReconciler validates a BindACL and reports readiness. The rendered ACL
// is emitted into named.conf by the BindCluster controller, which watches ACLs.
type BindACLReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls/status,verbs=get;update;patch
func (r *BindACLReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var acl bindv1alpha1.BindACL
if err := r.Get(ctx, req.NamespacedName, &acl); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
acl.Status.Ready = len(acl.Spec.Entries) > 0
acl.Status.ObservedGeneration = acl.Generation
setReady(&acl.Status.Conditions, acl.Generation, acl.Status.Ready, "Validated", "ACL rendered into named.conf")
if err := r.Status().Update(ctx, &acl); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindACLReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindACL{}).
Complete(r)
}
@@ -0,0 +1,111 @@
package controller
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindCatalogZoneReconciler creates and maintains the catalog zone on a cluster
// primary so secondaries auto-provision member zones.
type BindCatalogZoneReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch
func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var catalog bindv1alpha1.BindCatalogZone
if err := r.Get(ctx, req.NamespacedName, &catalog); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, catalog.Namespace, catalog.Spec.ClusterRef)
if err != nil {
return r.fail(ctx, &catalog, "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.fail(ctx, &catalog, "PrimaryNotReady", "waiting for cluster primary")
}
creds, err := resolveTSIG(ctx, r.Client, catalog.Namespace, catalog.Spec.TransferKeyRef)
if err != nil {
return r.fail(ctx, &catalog, "NoTransferKey", err.Error())
}
// Ensure the catalog zone exists on the primary.
if !r.Exec.ZoneExists(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "") {
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), "", 1); err != nil {
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
}
}
zoneConfig := fmt.Sprintf("{ type primary; file \"%s\"; allow-transfer { key \"%s\"; }; allow-update { key \"%s\"; }; };",
bind.CatalogFilePath(catalog.Spec.ZoneName), catalog.Spec.TransferKeyRef, catalog.Spec.TransferKeyRef)
if err := r.Exec.AddZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "", zoneConfig); err != nil {
return r.fail(ctx, &catalog, "AddZoneFailed", err.Error())
}
// Catalog zones must advertise their schema version (RFC 9432: "2").
versionUpdate := bind.RecordUpdate{
FQDN: "version." + catalog.Spec.ZoneName + ".",
Type: "TXT",
TTL: 3600,
Values: []string{"\"2\""},
}
if err := r.Exec.NSUpdate(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, creds, []bind.RecordUpdate{versionUpdate}); err != nil {
return r.fail(ctx, &catalog, "VersionUpdateFailed", err.Error())
}
// Count member zones for status.
var zones bindv1alpha1.BindZoneList
members := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(catalog.Namespace)); err == nil {
for i := range zones.Items {
z := &zones.Items[i]
if z.Spec.ClusterRef == cluster.Name && catalogEnabled(z) {
members++
}
}
}
catalog.Status.Ready = true
catalog.Status.MemberCount = members
catalog.Status.ObservedGeneration = catalog.Generation
setReady(&catalog.Status.Conditions, catalog.Generation, true, "Ready", "catalog zone provisioned")
if err := r.Status().Update(ctx, &catalog); err != nil {
return ctrl.Result{}, err
}
logger.Info("catalog zone reconciled", "zone", catalog.Spec.ZoneName, "members", members)
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *BindCatalogZoneReconciler) fail(ctx context.Context, catalog *bindv1alpha1.BindCatalogZone, reason, msg string) (ctrl.Result, error) {
catalog.Status.Ready = false
catalog.Status.ObservedGeneration = catalog.Generation
setReady(&catalog.Status.Conditions, catalog.Generation, false, reason, msg)
if err := r.Status().Update(ctx, catalog); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindCatalogZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindCatalogZone{}).
Complete(r)
}
@@ -0,0 +1,418 @@
package controller
import (
"context"
"fmt"
"sort"
"strings"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindClusterReconciler manages the StatefulSet, Services, ConfigMap and
// Secrets backing a BindCluster, and re-renders named.conf when dependent
// objects (ACLs, views, policies, keys, catalog) change.
type BindClusterReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls;bindviews;bindpolicies;binddnssecpolicies;bindcatalogzones;bindtsigkeys,verbs=get;list;watch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services;configmaps;secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=pods/exec,verbs=create;get
func (r *BindClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var cluster bindv1alpha1.BindCluster
if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if err := r.reconcileRNDCSecret(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("rndc secret: %w", err)
}
if err := r.reconcileKeysSecret(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("keys secret: %w", err)
}
if err := r.reconcileConfigMap(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("configmap: %w", err)
}
if err := r.reconcileServices(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("services: %w", err)
}
sts, err := r.reconcileStatefulSet(ctx, &cluster)
if err != nil {
return ctrl.Result{}, fmt.Errorf("statefulset: %w", err)
}
// Best-effort: reload configuration on ready pods so ConfigMap changes take
// effect without a rollout.
r.reloadReadyPods(ctx, &cluster)
// Status.
cluster.Status.ObservedGeneration = cluster.Generation
cluster.Status.Replicas = cluster.Spec.Replicas
cluster.Status.ReadyReplicas = sts.Status.ReadyReplicas
cluster.Status.PrimaryPod = primaryPodName(cluster.Name)
cluster.Status.PrimaryService = primaryAddress(cluster.Name, cluster.Namespace)
ready := sts.Status.ReadyReplicas == cluster.Spec.Replicas && cluster.Spec.Replicas > 0
if ready {
cluster.Status.Phase = "Ready"
} else {
cluster.Status.Phase = "Progressing"
}
setReady(&cluster.Status.Conditions, cluster.Generation, ready, "Reconciled",
fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, cluster.Spec.Replicas))
if err := r.Status().Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
if !ready {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
logger.V(1).Info("cluster reconciled", "cluster", cluster.Name, "ready", sts.Status.ReadyReplicas)
return ctrl.Result{}, nil
}
func (r *BindClusterReconciler) reconcileRNDCSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error {
name := rndcSecretName(c.Name)
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if err == nil {
return nil // rndc key is generated once and preserved
}
if !apierrors.IsNotFound(err) {
return err
}
secret, genErr := bind.GenerateSecret(32)
if genErr != nil {
return genErr
}
keyClause := bind.KeyClause("rndc-key", "hmac-sha256", secret)
rndcConf := fmt.Sprintf("include \"/etc/bind/rndc.key\";\noptions {\n default-key \"rndc-key\";\n default-server 127.0.0.1;\n default-port 953;\n};\n")
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: c.Namespace, Labels: commonLabels(c.Name)},
Data: map[string][]byte{
"rndc.key": []byte(keyClause),
"rndc.conf": []byte(rndcConf),
},
}
if err := ctrl.SetControllerReference(c, s, r.Scheme); err != nil {
return err
}
return r.Create(ctx, s)
}
func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error {
var keys bindv1alpha1.BindTSIGKeyList
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
return err
}
items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...)
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var b strings.Builder
b.WriteString("// Managed by bind-operator.\n")
for _, k := range items {
secretName := k.Status.SecretName
if secretName == "" {
secretName = k.Spec.SecretName
}
if secretName == "" {
secretName = k.Name + "-tsig"
}
var secret corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: secretName}, &secret); err != nil {
continue // key not yet materialised; skip until its controller runs
}
keyName := k.Spec.KeyName
if keyName == "" {
keyName = k.Name
}
alg := string(secret.Data["algorithm"])
if alg == "" {
alg = string(bindv1alpha1.TSIGHMACSHA256)
}
b.WriteString(bind.KeyClause(keyName, alg, string(secret.Data["secret"])))
}
return r.upsertSecret(ctx, c, keysSecretName(c.Name), map[string][]byte{"keys.conf": []byte(b.String())})
}
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)}
var acls bindv1alpha1.BindACLList
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
for _, a := range acls.Items {
if a.Spec.ClusterRef == "" || a.Spec.ClusterRef == c.Name {
in.ACLs = append(in.ACLs, a)
}
}
}
var views bindv1alpha1.BindViewList
if err := r.List(ctx, &views, client.InNamespace(c.Namespace)); err == nil {
for _, v := range views.Items {
if v.Spec.ClusterRef == c.Name {
in.Views = append(in.Views, v)
}
}
}
var policies bindv1alpha1.BindPolicyList
if err := r.List(ctx, &policies, client.InNamespace(c.Namespace)); err == nil {
for _, p := range policies.Items {
if p.Spec.ClusterRef == c.Name {
in.Policies = append(in.Policies, p)
}
}
}
var dnssec bindv1alpha1.BindDNSSECPolicyList
if err := r.List(ctx, &dnssec, client.InNamespace(c.Namespace)); err == nil {
for _, d := range dnssec.Items {
if d.Spec.ClusterRef == c.Name {
in.DNSSECPolicies = append(in.DNSSECPolicies, d)
}
}
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == c.Name {
in.Catalog = &catalogs.Items[i]
break
}
}
}
primaryConf, secondaryConf := bind.RenderNamedConf(in)
data := map[string]string{
"named.conf.primary": primaryConf,
"named.conf.secondary": secondaryConf,
"entrypoint.sh": entrypointScript(),
}
return r.upsertConfigMap(ctx, c, configMapName(c.Name), data)
}
func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1alpha1.BindCluster) error {
dnsPorts := []corev1.ServicePort{
{Name: "dns-udp", Port: 53, Protocol: corev1.ProtocolUDP, TargetPort: intstrFromInt(53)},
{Name: "dns-tcp", Port: 53, Protocol: corev1.ProtocolTCP, TargetPort: intstrFromInt(53)},
}
headless := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: headlessServiceName(c.Name), Namespace: c.Namespace, Labels: commonLabels(c.Name)},
Spec: corev1.ServiceSpec{
ClusterIP: corev1.ClusterIPNone,
PublishNotReadyAddresses: true,
Selector: commonLabels(c.Name),
Ports: dnsPorts,
},
}
if err := r.upsertService(ctx, c, headless); err != nil {
return err
}
svcType := c.Spec.Service.Type
if svcType == "" {
svcType = corev1.ServiceTypeClusterIP
}
client := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clientServiceName(c.Name),
Namespace: c.Namespace,
Labels: commonLabels(c.Name),
Annotations: c.Spec.Service.Annotations,
},
Spec: corev1.ServiceSpec{
Type: svcType,
Selector: commonLabels(c.Name),
Ports: dnsPorts,
LoadBalancerIP: c.Spec.Service.LoadBalancerIP,
},
}
return r.upsertService(ctx, c, client)
}
func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) {
labels := commonLabels(c.Name)
replicas := c.Spec.Replicas
image := c.Spec.Image
if image == "" {
image = "git.unkin.net/unkin/bind9:latest"
}
storageSize := c.Spec.StorageSize
if storageSize == "" {
storageSize = "1Gi"
}
qty, err := resource.ParseQuantity(storageSize)
if err != nil {
return nil, fmt.Errorf("parse storageSize: %w", err)
}
projected := corev1.Volume{
Name: "bind-etc",
VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{
{ConfigMap: &corev1.ConfigMapProjection{LocalObjectReference: corev1.LocalObjectReference{Name: configMapName(c.Name)}}},
{Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: keysSecretName(c.Name)}}},
{Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: rndcSecretName(c.Name)}}},
}}},
}
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
Spec: appsv1.StatefulSetSpec{
ServiceName: headlessServiceName(c.Name),
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations,
Affinity: c.Spec.Affinity,
Containers: []corev1.Container{{
Name: bind.ContainerName,
Image: image,
ImagePullPolicy: c.Spec.ImagePullPolicy,
Command: []string{"/bin/sh", "/etc/bind/entrypoint.sh"},
Ports: []corev1.ContainerPort{
{Name: "dns-udp", ContainerPort: 53, Protocol: corev1.ProtocolUDP},
{Name: "dns-tcp", ContainerPort: 53, Protocol: corev1.ProtocolTCP},
},
Resources: c.Spec.Resources,
VolumeMounts: []corev1.VolumeMount{
{Name: "bind-etc", MountPath: "/etc/bind", ReadOnly: true},
{Name: "run", MountPath: "/run/named"},
{Name: "data", MountPath: bind.DataDir},
},
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}},
InitialDelaySeconds: 5,
PeriodSeconds: 10,
},
LivenessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}},
InitialDelaySeconds: 15,
PeriodSeconds: 20,
},
}},
Volumes: []corev1.Volume{
projected,
{Name: "run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
},
},
},
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
StorageClassName: c.Spec.StorageClassName,
Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: qty}},
},
}},
},
}
if err := ctrl.SetControllerReference(c, sts, r.Scheme); err != nil {
return nil, err
}
var existing appsv1.StatefulSet
err = r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: c.Name}, &existing)
if apierrors.IsNotFound(err) {
return sts, r.Create(ctx, sts)
}
if err != nil {
return nil, err
}
// VolumeClaimTemplates are immutable; only mutate the mutable fields.
existing.Spec.Replicas = sts.Spec.Replicas
existing.Spec.Template = sts.Spec.Template
if err := r.Update(ctx, &existing); err != nil {
return nil, err
}
return &existing, nil
}
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
if r.Exec == nil {
return
}
logger := log.FromContext(ctx)
var pods corev1.PodList
if err := r.List(ctx, &pods, client.InNamespace(c.Namespace), client.MatchingLabels(commonLabels(c.Name))); err != nil {
return
}
for i := range pods.Items {
pod := &pods.Items[i]
if !podReady(pod) {
continue
}
if err := r.Exec.Reconfig(ctx, c.Namespace, pod.Name); err != nil {
logger.V(1).Info("rndc reconfig failed", "pod", pod.Name, "err", err.Error())
}
}
}
func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
mapToCluster := func(clusterRef, namespace string) []reconcile.Request {
if clusterRef == "" {
return nil
}
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: namespace, Name: clusterRef}}}
}
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindCluster{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Owns(&corev1.Secret{}).
Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindView{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindView).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindPolicy).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindTSIGKey{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// TSIG keys are namespace-wide; re-render every cluster in the namespace.
var clusters bindv1alpha1.BindClusterList
if err := r.List(ctx, &clusters, client.InNamespace(o.GetNamespace())); err != nil {
return nil
}
var reqs []reconcile.Request
for _, cl := range clusters.Items {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: cl.Namespace, Name: cl.Name}})
}
return reqs
})).
Complete(r)
}
@@ -0,0 +1,55 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindDNSSECPolicyReconciler validates a signing policy and reports how many
// zones reference it. The dnssec-policy block is rendered into named.conf by
// the BindCluster controller, which watches these policies.
type BindDNSSECPolicyReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch
func (r *BindDNSSECPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var policy bindv1alpha1.BindDNSSECPolicy
if err := r.Get(ctx, req.NamespacedName, &policy); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zones bindv1alpha1.BindZoneList
count := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(policy.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == policy.Spec.ClusterRef && z.Spec.DNSSECPolicyRef == policy.Name {
count++
}
}
}
policy.Status.ZoneCount = count
policy.Status.Ready = policy.Spec.ClusterRef != ""
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, policy.Status.Ready, "Validated", "dnssec-policy rendered into named.conf")
if err := r.Status().Update(ctx, &policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindDNSSECPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindDNSSECPolicy{}).
Complete(r)
}
@@ -0,0 +1,163 @@
package controller
import (
"context"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindPolicyReconciler provisions a Response Policy Zone (RPZ) on a cluster
// primary and seeds its rules. The cluster controller renders the matching
// response-policy clause into named.conf.
type BindPolicyReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch
func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var policy bindv1alpha1.BindPolicy
if err := r.Get(ctx, req.NamespacedName, &policy); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, policy.Namespace, policy.Spec.ClusterRef)
if err != nil {
return r.fail(ctx, &policy, "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.fail(ctx, &policy, "PrimaryNotReady", "waiting for cluster primary")
}
// Externally-fed RPZ: configure as a secondary of the feed. Otherwise host a
// locally-populated primary RPZ zone.
if len(policy.Spec.Primaries) > 0 {
creds, _ := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef)
_ = creds
cfg := fmt.Sprintf("{ type secondary; file \"%s\"; primaries { %s }; };",
bind.ZoneFilePath(policy.Spec.ZoneName), terminateInline(policy.Spec.Primaries))
if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil {
return r.fail(ctx, &policy, "AddZoneFailed", err.Error())
}
return r.ready(ctx, &policy, int32(0))
}
creds, err := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef)
if err != nil {
return r.fail(ctx, &policy, "NoUpdateKey", "spec.transferKeyRef required to seed RPZ rules")
}
if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) {
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil {
return r.fail(ctx, &policy, "SeedFailed", err.Error())
}
}
cfg := fmt.Sprintf("{ type primary; file \"%s\"; allow-update { key \"%s\"; }; };",
bind.ZoneFilePath(policy.Spec.ZoneName), policy.Spec.TransferKeyRef)
if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil {
return r.fail(ctx, &policy, "AddZoneFailed", err.Error())
}
updates := rpzRulesToUpdates(policy.Spec.ZoneName, policy.Spec.Rules)
if len(updates) > 0 {
if err := r.Exec.NSUpdate(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, creds, updates); err != nil {
return r.fail(ctx, &policy, "RuleUpdateFailed", err.Error())
}
}
logger.Info("policy reconciled", "zone", policy.Spec.ZoneName, "rules", len(updates))
return r.ready(ctx, &policy, int32(len(updates)))
}
// rpzRulesToUpdates maps RPZ rules to the CNAME records that encode them.
func rpzRulesToUpdates(rpzZone string, rules []bindv1alpha1.RPZRule) []bind.RecordUpdate {
var updates []bind.RecordUpdate
origin := strings.TrimSuffix(rpzZone, ".") + "."
for _, rule := range rules {
trigger := rule.Trigger
if trigger == "" {
trigger = "qname"
}
match := strings.TrimSuffix(strings.TrimSpace(rule.Match), ".")
var owner string
switch trigger {
case "qname":
owner = match + "." + origin
case "client-ip":
owner = match + ".rpz-client-ip." + origin
case "ip":
owner = match + ".rpz-ip." + origin
case "nsdname":
owner = match + ".rpz-nsdname." + origin
case "nsip":
owner = match + ".rpz-nsip." + origin
default:
owner = match + "." + origin
}
action := rule.Action
if action == "" {
action = "nxdomain"
}
var rdata string
switch action {
case "nxdomain":
rdata = "."
case "nodata":
rdata = "*."
case "passthru":
rdata = "rpz-passthru."
case "drop":
rdata = "rpz-drop."
case "tcp-only":
rdata = "rpz-tcp-only."
case "cname":
rdata = strings.TrimSuffix(rule.Target, ".") + "."
default:
rdata = "."
}
updates = append(updates, bind.RecordUpdate{FQDN: owner, Type: "CNAME", TTL: 3600, Values: []string{rdata}})
}
return updates
}
func (r *BindPolicyReconciler) ready(ctx context.Context, policy *bindv1alpha1.BindPolicy, rules int32) (ctrl.Result, error) {
policy.Status.Ready = true
policy.Status.RuleCount = rules
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, true, "Ready", "RPZ provisioned")
if err := r.Status().Update(ctx, policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *BindPolicyReconciler) fail(ctx context.Context, policy *bindv1alpha1.BindPolicy, reason, msg string) (ctrl.Result, error) {
policy.Status.Ready = false
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, false, reason, msg)
if err := r.Status().Update(ctx, policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindPolicy{}).
Complete(r)
}
@@ -0,0 +1,108 @@
package controller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindTSIGKeyReconciler generates TSIG key material into a Secret.
type BindTSIGKeyReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var key bindv1alpha1.BindTSIGKey
if err := r.Get(ctx, req.NamespacedName, &key); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
algorithm := string(key.Spec.Algorithm)
if algorithm == "" {
algorithm = string(bindv1alpha1.TSIGHMACSHA256)
}
keyName := key.Spec.KeyName
if keyName == "" {
keyName = key.Name
}
secretName := key.Spec.SecretName
if secretName == "" {
secretName = key.Name + "-tsig"
}
var secret corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: key.Namespace, Name: secretName}, &secret)
switch {
case apierrors.IsNotFound(err):
if key.Spec.ImportExisting {
return r.fail(ctx, &key, "SecretMissing", fmt.Sprintf("import secret %s not found", secretName))
}
material, genErr := bind.GenerateSecret(bind.SecretBytesForAlgorithm(algorithm))
if genErr != nil {
return ctrl.Result{}, genErr
}
newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
Data: map[string][]byte{
"algorithm": []byte(algorithm),
"keyName": []byte(keyName),
"secret": []byte(material),
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
},
}
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, newSecret); err != nil {
return ctrl.Result{}, err
}
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
case err != nil:
return ctrl.Result{}, err
}
key.Status.SecretName = secretName
key.Status.KeyName = keyName
key.Status.Ready = true
key.Status.ObservedGeneration = key.Generation
setReady(&key.Status.Conditions, key.Generation, true, "KeyReady", "TSIG key material present")
if err := r.Status().Update(ctx, &key); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
key.Status.Ready = false
key.Status.ObservedGeneration = key.Generation
setReady(&key.Status.Conditions, key.Generation, false, reason, msg)
if err := r.Status().Update(ctx, key); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindTSIGKeyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindTSIGKey{}).
Owns(&corev1.Secret{}).
Complete(r)
}
@@ -0,0 +1,55 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindViewReconciler validates a BindView and reports the number of zones bound
// to it. The view block is rendered into named.conf by the BindCluster
// controller, which watches views.
type BindViewReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch
func (r *BindViewReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var view bindv1alpha1.BindView
if err := r.Get(ctx, req.NamespacedName, &view); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zones bindv1alpha1.BindZoneList
count := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(view.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == view.Spec.ClusterRef && z.Spec.ViewRef == view.Name {
count++
}
}
}
view.Status.ZoneCount = count
view.Status.Ready = view.Spec.ClusterRef != ""
view.Status.ObservedGeneration = view.Generation
setReady(&view.Status.Conditions, view.Generation, view.Status.Ready, "Validated", "view rendered into named.conf")
if err := r.Status().Update(ctx, &view); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindViewReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindView{}).
Complete(r)
}
+223
View File
@@ -0,0 +1,223 @@
package controller
import (
"context"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindZoneReconciler provisions zones on a cluster primary via rndc addzone and
// seeds records via dynamic update.
type BindZoneReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones;bindtsigkeys,verbs=get;list;watch
func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var zone bindv1alpha1.BindZone
if err := r.Get(ctx, req.NamespacedName, &zone); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
// Handle deletion via finalizer: remove the zone from the primary and catalog.
if !zone.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&zone, finalizer) {
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
_ = r.Exec.DelZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
r.deregisterCatalog(ctx, &zone, cluster, primaryPod)
}
controllerutil.RemoveFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&zone, finalizer) {
controllerutil.AddFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && zone.Spec.Type == bindv1alpha1.ZonePrimary || (created && zone.Spec.Type == "") {
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), "", 1); err != nil {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
if err := r.Exec.AddZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef, zoneConfig); err != nil {
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
}
// Seed static records (primary zones only).
recordCount := 0
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
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())
}
recordCount = len(updates)
}
// Register in the catalog so secondaries auto-provision.
if catalogEnabled(&zone) {
r.registerCatalog(ctx, &zone, cluster, primaryPod)
}
serial, _ := r.Exec.ZoneSerial(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
zone.Status.Phase = "Ready"
zone.Status.Serial = serial
zone.Status.RecordCount = int32(recordCount)
zone.Status.Signed = zone.Spec.DNSSECPolicyRef != ""
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Provisioned", "zone provisioned on primary")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
logger.Info("zone reconciled", "zone", zone.Spec.ZoneName, "serial", serial)
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
}
var parts []string
switch zType {
case bindv1alpha1.ZonePrimary:
parts = append(parts, "type primary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
}
if len(zone.Spec.AllowTransfer) > 0 {
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
}
case bindv1alpha1.ZoneSecondary:
parts = append(parts, "type secondary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
case bindv1alpha1.ZoneForward:
parts = append(parts, "type forward", "forward only")
if len(zone.Spec.Forwarders) > 0 {
parts = append(parts, fmt.Sprintf("forwarders { %s }", terminateInline(zone.Spec.Forwarders)))
}
case bindv1alpha1.ZoneStub:
parts = append(parts, "type stub", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
}
return "{ " + strings.Join(parts, "; ") + "; };", nil
}
func (r *BindZoneReconciler) zoneUpdateCreds(ctx context.Context, zone *bindv1alpha1.BindZone) (bind.TSIGCreds, error) {
keyRef := zone.Spec.UpdateKeyRef
if keyRef == "" {
keyRef = zone.Spec.TransferKeyRef
}
if keyRef == "" {
// Fall back to local (non-TSIG) update when the zone allows it; most
// seeded primaries permit localhost updates.
return bind.TSIGCreds{}, fmt.Errorf("records require spec.updateKeyRef")
}
return resolveTSIG(ctx, r.Client, zone.Namespace, keyRef)
}
func (r *BindZoneReconciler) registerCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
logger := log.FromContext(ctx)
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
if err := r.Exec.AddCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds); err != nil {
logger.V(1).Info("catalog register failed", "zone", zone.Spec.ZoneName, "err", err.Error())
}
}
func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
}
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
return nil, bind.TSIGCreds{}, false
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
cat := &catalogs.Items[i]
creds, err := resolveTSIG(ctx, r.Client, zone.Namespace, cat.Spec.TransferKeyRef)
if err != nil {
return nil, bind.TSIGCreds{}, false
}
return cat, creds, true
}
}
return nil, bind.TSIGCreds{}, false
}
func (r *BindZoneReconciler) setPhase(ctx context.Context, zone *bindv1alpha1.BindZone, phase, reason, msg string) (ctrl.Result, error) {
zone.Status.Phase = phase
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, phase == "Ready", reason, msg)
if err := r.Status().Update(ctx, zone); err != nil {
return ctrl.Result{}, err
}
if phase == "Error" || phase == "Pending" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, nil
}
func (r *BindZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindZone{}).
Complete(r)
}
+116
View File
@@ -0,0 +1,116 @@
package controller
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// DNSRecordReconciler applies individual record sets to a zone via TSIG dynamic
// update — the external-dns write path as a CRD.
type DNSRecordReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch
func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var record bindv1alpha1.DNSRecord
if err := r.Get(ctx, req.NamespacedName, &record); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zone bindv1alpha1.BindZone
if err := r.Get(ctx, client.ObjectKey{Namespace: record.Namespace, Name: record.Spec.ZoneRef}, &zone); err != nil {
return r.setPhase(ctx, &record, "Error", "ZoneMissing", err.Error())
}
cluster, err := getCluster(ctx, r.Client, record.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &record, "Error", "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
name := fqdn(record.Spec.Name, zone.Spec.ZoneName)
creds, err := resolveTSIG(ctx, r.Client, record.Namespace, zone.Spec.UpdateKeyRef)
if err != nil {
return r.setPhase(ctx, &record, "Error", "NoUpdateKey", fmt.Sprintf("zone %s: %v", zone.Name, err))
}
// Deletion via finalizer: remove the RRset.
if !record.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&record, finalizer) {
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
_ = r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds,
[]bind.RecordUpdate{{FQDN: name, Type: record.Spec.Type, Delete: true}})
}
controllerutil.RemoveFinalizer(&record, finalizer)
if err := r.Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&record, finalizer) {
controllerutil.AddFinalizer(&record, finalizer)
if err := r.Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
}
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.setPhase(ctx, &record, "Pending", "PrimaryNotReady", "waiting for cluster primary")
}
ttl := zone.Spec.DefaultTTL
if record.Spec.TTL != nil {
ttl = *record.Spec.TTL
}
update := bind.RecordUpdate{FQDN: name, Type: record.Spec.Type, TTL: ttl, Values: record.Spec.Values}
if err := r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds, []bind.RecordUpdate{update}); err != nil {
return r.setPhase(ctx, &record, "Error", "UpdateFailed", err.Error())
}
record.Status.FQDN = name
record.Status.Phase = "Applied"
record.Status.ObservedGeneration = record.Generation
setReady(&record.Status.Conditions, record.Generation, true, "Applied", "record applied via dynamic update")
if err := r.Status().Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
logger.Info("record applied", "record", name, "type", record.Spec.Type)
return ctrl.Result{}, nil
}
func (r *DNSRecordReconciler) setPhase(ctx context.Context, record *bindv1alpha1.DNSRecord, phase, reason, msg string) (ctrl.Result, error) {
record.Status.Phase = phase
record.Status.ObservedGeneration = record.Generation
setReady(&record.Status.Conditions, record.Generation, phase == "Applied", reason, msg)
if err := r.Status().Update(ctx, record); err != nil {
return ctrl.Result{}, err
}
if phase == "Error" || phase == "Pending" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, nil
}
func (r *DNSRecordReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.DNSRecord{}).
Complete(r)
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
const (
requeueShort = 15 * time.Second
requeueLong = 2 * time.Minute
managedByLabel = "app.kubernetes.io/managed-by"
managedByValue = "bind-operator"
clusterLabel = "bind.unkin.net/cluster"
finalizer = "bind.unkin.net/finalizer"
)
func headlessServiceName(cluster string) string { return cluster + "-headless" }
func clientServiceName(cluster string) string { return cluster }
func primaryPodName(cluster string) string { return cluster + "-0" }
func configMapName(cluster string) string { return cluster + "-config" }
func keysSecretName(cluster string) string { return cluster + "-keys" }
func rndcSecretName(cluster string) string { return cluster + "-rndc" }
// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0).
func primaryAddress(cluster, namespace string) string {
return fmt.Sprintf("%s-0.%s.%s.svc.cluster.local", cluster, headlessServiceName(cluster), namespace)
}
// setReady sets the standard Ready condition on a status conditions slice.
func setReady(conds *[]metav1.Condition, gen int64, ok bool, reason, msg string) {
status := metav1.ConditionFalse
if ok {
status = metav1.ConditionTrue
}
meta.SetStatusCondition(conds, metav1.Condition{
Type: "Ready",
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: gen,
})
}
// commonLabels are applied to every object the operator creates for a cluster.
func commonLabels(cluster string) map[string]string {
return map[string]string{
managedByLabel: managedByValue,
clusterLabel: cluster,
}
}
// getCluster fetches the BindCluster referenced by clusterRef in namespace.
func getCluster(ctx context.Context, c client.Client, namespace, clusterRef string) (*bindv1alpha1.BindCluster, error) {
var cluster bindv1alpha1.BindCluster
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: clusterRef}, &cluster); err != nil {
return nil, err
}
return &cluster, nil
}
// primaryReady reports whether the primary pod of a cluster is Ready.
func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) bool {
var pod corev1.Pod
key := client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)}
if err := c.Get(ctx, key, &pod); err != nil {
return false
}
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady {
return cond.Status == corev1.ConditionTrue
}
}
return false
}
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
var creds bind.TSIGCreds
if keyRef == "" {
return creds, fmt.Errorf("no TSIG key referenced")
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: keyRef}, &key); err != nil {
return creds, fmt.Errorf("get tsig key %s: %w", keyRef, err)
}
secretName := key.Status.SecretName
if secretName == "" {
secretName = key.Spec.SecretName
}
if secretName == "" {
secretName = keyRef + "-tsig"
}
var secret corev1.Secret
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: secretName}, &secret); err != nil {
return creds, fmt.Errorf("get tsig secret %s: %w", secretName, err)
}
keyName := key.Spec.KeyName
if keyName == "" {
keyName = keyRef
}
creds = bind.TSIGCreds{
Name: keyName,
Algorithm: string(secret.Data["algorithm"]),
Secret: string(secret.Data["secret"]),
}
if creds.Algorithm == "" {
creds.Algorithm = string(bindv1alpha1.TSIGHMACSHA256)
}
return creds, nil
}
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
ctrl "sigs.k8s.io/controller-runtime"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// SetupAll registers every controller with the manager.
func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error {
if err := (&BindClusterReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindTSIGKeyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindACLReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindViewReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindDNSSECPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindCatalogZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
ctrl "sigs.k8s.io/controller-runtime"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func intstrFromInt(i int) intstr.IntOrString { return intstr.FromInt(i) }
func podReady(pod *corev1.Pod) bool {
for _, c := range pod.Status.Conditions {
if c.Type == corev1.PodReady {
return c.Status == corev1.ConditionTrue
}
}
return false
}
// entrypointScript selects the primary or secondary named.conf based on the
// pod's StatefulSet ordinal and launches named in the foreground.
func entrypointScript() string {
return `#!/bin/sh
set -eu
ORD="${HOSTNAME##*-}"
if [ "$ORD" = "0" ]; then
cp /etc/bind/named.conf.primary /run/named/named.conf
else
cp /etc/bind/named.conf.secondary /run/named/named.conf
fi
mkdir -p /var/lib/named/zones /var/lib/named/catalog
exec named -g -c /run/named/named.conf
`
}
func (r *BindClusterReconciler) upsertService(ctx context.Context, c *bindv1alpha1.BindCluster, desired *corev1.Service) error {
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.Service
err := r.Get(ctx, types.NamespacedName{Namespace: desired.Namespace, Name: desired.Name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Spec.Ports = desired.Spec.Ports
existing.Spec.Selector = desired.Spec.Selector
existing.Spec.Type = desired.Spec.Type
existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP
if desired.Annotations != nil {
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
for k, v := range desired.Annotations {
existing.Annotations[k] = v
}
}
return r.Update(ctx, &existing)
}
func (r *BindClusterReconciler) upsertConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string]string) error {
desired := &corev1.ConfigMap{}
desired.Name = name
desired.Namespace = c.Namespace
desired.Labels = commonLabels(c.Name)
desired.Data = data
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.ConfigMap
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Data = data
existing.Labels = commonLabels(c.Name)
return r.Update(ctx, &existing)
}
func (r *BindClusterReconciler) upsertSecret(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string][]byte) error {
desired := &corev1.Secret{}
desired.Name = name
desired.Namespace = c.Namespace
desired.Labels = commonLabels(c.Name)
desired.Data = data
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Data = data
existing.Labels = commonLabels(c.Name)
return r.Update(ctx, &existing)
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"context"
"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 {
ref := zone.Spec.UpdateKeyRef
if ref == "" {
return ""
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: zone.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, " ")
}
+79
View File
@@ -0,0 +1,79 @@
package controller
import (
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestFQDN(t *testing.T) {
cases := []struct{ name, zone, want string }{
{"@", "example.com", "example.com."},
{"", "example.com", "example.com."},
{"www", "example.com", "www.example.com."},
{"www.example.com.", "example.com", "www.example.com."},
{"host", "10.in-addr.arpa", "host.10.in-addr.arpa."},
}
for _, c := range cases {
if got := fqdn(c.name, c.zone); got != c.want {
t.Errorf("fqdn(%q,%q)=%q want %q", c.name, c.zone, got, c.want)
}
}
}
func TestRecordsToUpdatesTTLFallback(t *testing.T) {
custom := int32(60)
records := []bindv1alpha1.Record{
{Name: "@", Type: "A", Values: []string{"192.0.2.1"}},
{Name: "low", Type: "A", TTL: &custom, Values: []string{"192.0.2.2"}},
}
updates := recordsToUpdates("example.com", records, 3600)
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
if updates[0].TTL != 3600 {
t.Errorf("expected default TTL 3600, got %d", updates[0].TTL)
}
if updates[1].TTL != 60 {
t.Errorf("expected record TTL 60, got %d", updates[1].TTL)
}
if updates[0].FQDN != "example.com." {
t.Errorf("apex FQDN wrong: %s", updates[0].FQDN)
}
}
func TestRPZRulesToUpdates(t *testing.T) {
rules := []bindv1alpha1.RPZRule{
{Trigger: "qname", Match: "bad.example.com", Action: "nxdomain"},
{Trigger: "qname", Match: "walled.example.com", Action: "cname", Target: "block.internal"},
}
updates := rpzRulesToUpdates("rpz.internal", rules)
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
if updates[0].FQDN != "bad.example.com.rpz.internal." {
t.Errorf("qname owner wrong: %s", updates[0].FQDN)
}
if updates[0].Values[0] != "." {
t.Errorf("nxdomain rdata should be '.', got %q", updates[0].Values[0])
}
if updates[1].Values[0] != "block.internal." {
t.Errorf("cname rdata wrong: %q", updates[1].Values[0])
}
}
func TestCatalogEnabledDefault(t *testing.T) {
on := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary}}
if !catalogEnabled(on) {
t.Error("primary zone should default to catalog enabled")
}
no := false
off := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary, Catalog: &no}}
if catalogEnabled(off) {
t.Error("catalog=false should disable membership")
}
sec := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZoneSecondary}}
if catalogEnabled(sec) {
t.Error("secondary zone should never be a catalog member")
}
}