From 9c81320df8e1e0db9798d0570cbe14c9a1ab64be Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 20 Jul 2026 23:45:41 +1000 Subject: [PATCH] BindTSIGKey: add secretTemplate for labels/annotations on the managed Secret The operator-generated TSIG Secret previously carried only the managed-by label, so it could not be mirrored to another namespace by emberstack reflector (which requires reflection-allowed annotations on the source). Add spec.secretTemplate.{annotations,labels}, applied both when the Secret is first generated and reconciled onto the existing Secret when the CR changes (imported secrets are left untouched so we don't fight their external manager). This lets the external-dns TSIG key be managed in bind-internal and reflected into the externaldns namespace. --- README.md | 2 +- api/v1alpha1/bindtsigkey_types.go | 18 +++++ api/v1alpha1/zz_generated.deepcopy.go | 36 +++++++++- .../bases/bind.unkin.net_bindtsigkeys.yaml | 17 +++++ config/crd/install.yaml | 17 +++++ config/samples/00-tsigkeys.yaml | 10 ++- go.mod | 2 +- internal/controller/bindtsigkey_controller.go | 54 +++++++++++++- internal/controller/bindtsigkey_test.go | 71 +++++++++++++++++++ 9 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 internal/controller/bindtsigkey_test.go diff --git a/README.md b/README.md index 8dd5fe4..a4abdf1 100644 --- a/README.md +++ b/README.md @@ -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. | | `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). | -| `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`. | | `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. | | `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. | diff --git a/api/v1alpha1/bindtsigkey_types.go b/api/v1alpha1/bindtsigkey_types.go index aa3f6ea..94316a1 100644 --- a/api/v1alpha1/bindtsigkey_types.go +++ b/api/v1alpha1/bindtsigkey_types.go @@ -41,6 +41,24 @@ type BindTSIGKeySpec struct { // `secret` key and the operator will not generate new material. // +optional 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. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1cfcd39..d0eb94e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) 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. func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) { *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. @@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record { in.DeepCopyInto(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 +} diff --git a/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml b/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml index afdefcb..8f152e9 100644 --- a/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml +++ b/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml @@ -87,6 +87,23 @@ spec: SecretName is the Secret the key material is written to (or read from when ImportExisting is set). Defaults to "-tsig". 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 status: description: BindTSIGKeyStatus reports observed TSIG key state. diff --git a/config/crd/install.yaml b/config/crd/install.yaml index bd96752..67221fe 100644 --- a/config/crd/install.yaml +++ b/config/crd/install.yaml @@ -2376,6 +2376,23 @@ spec: SecretName is the Secret the key material is written to (or read from when ImportExisting is set). Defaults to "-tsig". 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 status: description: BindTSIGKeyStatus reports observed TSIG key state. diff --git a/config/samples/00-tsigkeys.yaml b/config/samples/00-tsigkeys.yaml index 7322d6a..71b76e1 100644 --- a/config/samples/00-tsigkeys.yaml +++ b/config/samples/00-tsigkeys.yaml @@ -11,7 +11,9 @@ spec: algorithm: hmac-sha256 --- # 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 kind: BindTSIGKey metadata: @@ -19,3 +21,9 @@ metadata: namespace: bind-externaldns spec: 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" diff --git a/go.mod b/go.mod index f17bf14..b53ccf9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator go 1.25 require ( + github.com/go-logr/logr v1.4.2 k8s.io/api v0.34.4 k8s.io/apimachinery 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/fsnotify/fsnotify v1.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-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect diff --git a/internal/controller/bindtsigkey_controller.go b/internal/controller/bindtsigkey_controller.go index 23466cc..667193b 100644 --- a/internal/controller/bindtsigkey_controller.go +++ b/internal/controller/bindtsigkey_controller.go @@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, genErr } 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{ "algorithm": []byte(algorithm), "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)), }, } + applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate) if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil { 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) case err != nil: 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 @@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) 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) { key.Status.Ready = false key.Status.ObservedGeneration = key.Generation diff --git a/internal/controller/bindtsigkey_test.go b/internal/controller/bindtsigkey_test.go new file mode 100644 index 0000000..7fd39f5 --- /dev/null +++ b/internal/controller/bindtsigkey_test.go @@ -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"]) + } + }) +}