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"]) + } + }) +}