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.
This commit is contained in:
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
|
|||||||
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
||||||
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
||||||
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
||||||
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). |
|
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
|
||||||
| `BindACL` | A reusable named `address_match_list`. |
|
| `BindACL` | A reusable named `address_match_list`. |
|
||||||
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
||||||
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
|
|||||||
// `secret` key and the operator will not generate new material.
|
// `secret` key and the operator will not generate new material.
|
||||||
// +optional
|
// +optional
|
||||||
ImportExisting bool `json:"importExisting,omitempty"`
|
ImportExisting bool `json:"importExisting,omitempty"`
|
||||||
|
|
||||||
|
// SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
// Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
// another namespace. Operator-managed labels are always preserved.
|
||||||
|
// +optional
|
||||||
|
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretMetadata carries extra labels and annotations to stamp onto a
|
||||||
|
// Secret managed by the operator.
|
||||||
|
type SecretMetadata struct {
|
||||||
|
// Annotations to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
|
||||||
|
// Labels to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindTSIGKeyStatus reports observed TSIG key state.
|
// BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.SecretTemplate != nil {
|
||||||
|
in, out := &in.SecretTemplate, &out.SecretTemplate
|
||||||
|
*out = new(SecretMetadata)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
||||||
@@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
|
|||||||
in.DeepCopyInto(out)
|
in.DeepCopyInto(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
|
||||||
|
*out = *in
|
||||||
|
if in.Annotations != nil {
|
||||||
|
in, out := &in.Annotations, &out.Annotations
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Labels != nil {
|
||||||
|
in, out := &in.Labels, &out.Labels
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
|
||||||
|
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(SecretMetadata)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -2376,6 +2376,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ spec:
|
|||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
---
|
---
|
||||||
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
||||||
# dynamic updates to the dynamic cluster's primary.
|
# dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
|
||||||
|
# generated Secret into the external-dns namespace via emberstack reflector, so
|
||||||
|
# external-dns presents exactly the key the primary's allow-update accepts.
|
||||||
apiVersion: bind.unkin.net/v1alpha1
|
apiVersion: bind.unkin.net/v1alpha1
|
||||||
kind: BindTSIGKey
|
kind: BindTSIGKey
|
||||||
metadata:
|
metadata:
|
||||||
@@ -19,3 +21,9 @@ metadata:
|
|||||||
namespace: bind-externaldns
|
namespace: bind-externaldns
|
||||||
spec:
|
spec:
|
||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
|
secretTemplate:
|
||||||
|
annotations:
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-logr/logr v1.4.2
|
||||||
k8s.io/api v0.34.4
|
k8s.io/api v0.34.4
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
@@ -17,7 +18,6 @@ require (
|
|||||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
|
||||||
github.com/go-logr/zapr v1.3.0 // indirect
|
github.com/go-logr/zapr v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, genErr
|
return ctrl.Result{}, genErr
|
||||||
}
|
}
|
||||||
newSecret := &corev1.Secret{
|
newSecret := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
|
||||||
Data: map[string][]byte{
|
Data: map[string][]byte{
|
||||||
"algorithm": []byte(algorithm),
|
"algorithm": []byte(algorithm),
|
||||||
"keyName": []byte(keyName),
|
"keyName": []byte(keyName),
|
||||||
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
|
||||||
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
|
default:
|
||||||
|
// Secret already exists: reconcile the template-managed metadata so that
|
||||||
|
// annotation/label changes on the CR (e.g. reflection hints) propagate
|
||||||
|
// without regenerating key material. Skip imported secrets, which are
|
||||||
|
// owned by an external manager (Vault/VSO, reflector) that we must not
|
||||||
|
// fight over metadata.
|
||||||
|
if key.Spec.ImportExisting {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
|
||||||
|
if err := r.Update(ctx, &secret); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key.Status.SecretName = secretName
|
key.Status.SecretName = secretName
|
||||||
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySecretTemplate stamps the operator-managed label plus any
|
||||||
|
// user-supplied labels/annotations onto the Secret's metadata. It returns true
|
||||||
|
// if it mutated meta, so callers can decide whether an update is needed.
|
||||||
|
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
|
||||||
|
changed := false
|
||||||
|
setLabel := func(k, v string) {
|
||||||
|
if meta.Labels == nil {
|
||||||
|
meta.Labels = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Labels[k] != v {
|
||||||
|
meta.Labels[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnnotation := func(k, v string) {
|
||||||
|
if meta.Annotations == nil {
|
||||||
|
meta.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Annotations[k] != v {
|
||||||
|
meta.Annotations[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabel(managedByLabel, managedByValue)
|
||||||
|
if tmpl != nil {
|
||||||
|
for k, v := range tmpl.Labels {
|
||||||
|
setLabel(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range tmpl.Annotations {
|
||||||
|
setAnnotation(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
||||||
key.Status.Ready = false
|
key.Status.Ready = false
|
||||||
key.Status.ObservedGeneration = key.Generation
|
key.Status.ObservedGeneration = key.Generation
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplySecretTemplate(t *testing.T) {
|
||||||
|
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
|
||||||
|
var meta metav1.ObjectMeta
|
||||||
|
if !applySecretTemplate(&meta, nil) {
|
||||||
|
t.Fatal("expected change on empty meta")
|
||||||
|
}
|
||||||
|
if meta.Labels[managedByLabel] != managedByValue {
|
||||||
|
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
|
||||||
|
}
|
||||||
|
if meta.Annotations != nil {
|
||||||
|
t.Errorf("annotations = %v, want nil", meta.Annotations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("applies labels and annotations", func(t *testing.T) {
|
||||||
|
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{
|
||||||
|
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
|
||||||
|
Labels: map[string]string{"team": "dns"},
|
||||||
|
}
|
||||||
|
if !applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Fatal("expected change when adding template metadata")
|
||||||
|
}
|
||||||
|
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
|
||||||
|
t.Errorf("reflection annotation = %q, want true", got)
|
||||||
|
}
|
||||||
|
if meta.Labels["team"] != "dns" {
|
||||||
|
t.Errorf("team label = %q, want dns", meta.Labels["team"])
|
||||||
|
}
|
||||||
|
// managed-by must survive user-supplied labels.
|
||||||
|
if meta.Labels[managedByLabel] != managedByValue {
|
||||||
|
t.Errorf("managed-by label dropped: %v", meta.Labels)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotent when already applied", func(t *testing.T) {
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{
|
||||||
|
Annotations: map[string]string{"a": "1"},
|
||||||
|
Labels: map[string]string{"b": "2"},
|
||||||
|
}
|
||||||
|
meta := metav1.ObjectMeta{}
|
||||||
|
applySecretTemplate(&meta, tmpl)
|
||||||
|
if applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Error("expected no change on second apply")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates drifted annotation value", func(t *testing.T) {
|
||||||
|
meta := metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{managedByLabel: managedByValue},
|
||||||
|
Annotations: map[string]string{"a": "old"},
|
||||||
|
}
|
||||||
|
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
|
||||||
|
if !applySecretTemplate(&meta, tmpl) {
|
||||||
|
t.Fatal("expected change when annotation value drifts")
|
||||||
|
}
|
||||||
|
if meta.Annotations["a"] != "new" {
|
||||||
|
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user