diff --git a/Makefile b/Makefile index 2df25c9..ce6e312 100644 --- a/Makefile +++ b/Makefile @@ -412,10 +412,15 @@ chainsaw: ## Find or download chainsaw $(call go-install-tool,$(CHAINSAW),github.com/kyverno/chainsaw,$(CHAINSAW_VERSION)) .PHONY: chainsaw-test -chainsaw-test: chainsaw chainsaw-prepare-kubeconfigs ## Run Chainsaw tests (requires dev/kind.*.kubeconfig to exist) +CHAINSAW_TEST_DIR ?= . +CHAINSAW_DEFAULT_ARGS ?= +CHAINSAW_ARGS ?= + +.PHONY: chainsaw-test +chainsaw-test: chainsaw chainsaw-prepare-kubeconfigs ## Run Chainsaw tests (set CHAINSAW_TEST_DIR=./tsig and/or CHAINSAW_ARGS="--include-test-regex ") @test -f dev/kind.upstream.kubeconfig || { echo "Missing dev/kind.upstream.kubeconfig. Bootstrap clusters first."; exit 1; } @test -f dev/kind.downstream.kubeconfig || { echo "Missing dev/kind.downstream.kubeconfig. Bootstrap clusters first."; exit 1; } - cd test/e2e && $(CHAINSAW) test . + cd test/e2e && $(CHAINSAW) test $(CHAINSAW_DEFAULT_ARGS) $(CHAINSAW_ARGS) $(CHAINSAW_TEST_DIR) .PHONY: chainsaw-prepare-kubeconfigs chainsaw-prepare-kubeconfigs: ## Copy dev kind kubeconfigs into test/e2e for stable relative resolution diff --git a/api/v1alpha1/tsigkey_types.go b/api/v1alpha1/dnszonetsigkey_types.go similarity index 100% rename from api/v1alpha1/tsigkey_types.go rename to api/v1alpha1/dnszonetsigkey_types.go diff --git a/cmd/main.go b/cmd/main.go index 895cb60..d4acc3f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -231,6 +231,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "DNSRecordSetPowerDNS") os.Exit(1) } + if err := (&controller.DNSZoneTSIGKeyPowerDNSReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DNSZoneTSIGKeyPowerDNS") + os.Exit(1) + } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") @@ -306,6 +313,12 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "DNSZoneDiscoveryReplicator") os.Exit(1) } + if err := (&controller.DNSZoneTSIGKeyReplicator{ + DownstreamClient: downstreamCluster.GetClient(), + }).SetupWithManager(mcmgr, downstreamCluster); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DNSZoneTSIGKeyReplicator") + os.Exit(1) + } if err := mcmgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 66d60bd..3032606 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,7 +9,8 @@ resources: - bases/dns.networking.miloapis.com_dnszonetsigkeys.yaml # +kubebuilder:scaffold:crdkustomizeresource -patches: +# kustomize expects this to be an array; keep empty by default. +patches: [] # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD # +kubebuilder:scaffold:crdkustomizewebhookpatch diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f138a63..b9e95d8 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -8,6 +8,7 @@ rules: - "" resources: - configmaps + - secrets verbs: - create - delete @@ -44,6 +45,7 @@ rules: - dns.networking.miloapis.com resources: - dnsrecordsets/finalizers + - dnszonetsigkeys/finalizers verbs: - update - apiGroups: @@ -52,6 +54,7 @@ rules: - dnsrecordsets/status - dnszonediscoveries/status - dnszones/status + - dnszonetsigkeys/status verbs: - get - patch @@ -68,6 +71,7 @@ rules: - dns.networking.miloapis.com resources: - dnszonediscoveries + - dnszonetsigkeys verbs: - get - list diff --git a/config/samples/dns_v1alpha1_dnszonetsigkey.yaml b/config/samples/dns_v1alpha1_dnszonetsigkey.yaml index 4db418c..3ea6e3b 100644 --- a/config/samples/dns_v1alpha1_dnszonetsigkey.yaml +++ b/config/samples/dns_v1alpha1_dnszonetsigkey.yaml @@ -10,5 +10,5 @@ spec: # algorithm: hmac-sha256 # Optional (BYO secret): # secretRef: - # name: existing-upstream-tsig + # name: existing-upstream diff --git a/internal/controller/conditions.go b/internal/controller/conditions.go index 637a57e..68daf1b 100644 --- a/internal/controller/conditions.go +++ b/internal/controller/conditions.go @@ -8,6 +8,7 @@ const ( ReasonAccepted = "Accepted" ReasonPending = "Pending" ReasonInvalidDNSRecordSet = "InvalidDNSRecordSet" + ReasonInvalidSecret = "InvalidSecret" ReasonProgrammed = "Programmed" ReasonDiscovered = "Discovered" ReasonDNSZoneInUse = "DNSZoneInUse" diff --git a/internal/controller/dnszonetsigkey_powerdns_controller.go b/internal/controller/dnszonetsigkey_powerdns_controller.go new file mode 100644 index 0000000..37bb850 --- /dev/null +++ b/internal/controller/dnszonetsigkey_powerdns_controller.go @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + pdnsclient "go.miloapis.com/dns-operator/internal/pdns" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +const dnsZoneTSIGKeyPowerDNSFinalizer = "dns.networking.miloapis.com/finalize-dnszonetsigkey-powerdns" + +// DNSZoneTSIGKeyPDNS is the subset of the PowerDNS client used by the DNSZoneTSIGKey controller. +type DNSZoneTSIGKeyPDNS interface { + EnsureTSIGKey(ctx context.Context, name, algorithm, keyMaterial string) (pdnsclient.TSIGKey, error) + DeleteTSIGKey(ctx context.Context, id string) error +} + +// DNSZoneTSIGKeyPowerDNSReconciler programs TSIG keys into PowerDNS. +type DNSZoneTSIGKeyPowerDNSReconciler struct { + client.Client + Scheme *runtime.Scheme + + // PDNS is optional; when nil, SetupWithManager constructs one from env via pdnsclient.NewFromEnv(). + PDNS DNSZoneTSIGKeyPDNS +} + +func qualifyTSIGKeyName(keyName, zoneDomain string) string { + // PowerDNS TSIG keys are stored by (DNS) name. Ensure we always send an FQDN + // for the key name by appending the zone domain when keyName is not already + // zone-qualified. + // + // Trailing dots are optional; preserve whether the user supplied one. + keyName = strings.TrimSpace(keyName) + zoneDomain = strings.TrimSpace(zoneDomain) + if keyName == "" || zoneDomain == "" { + return keyName + } + + hasTrailingDot := strings.HasSuffix(keyName, ".") + keyNoDot := strings.TrimSuffix(keyName, ".") + zoneNoDot := strings.TrimSuffix(zoneDomain, ".") + + lKey := strings.ToLower(keyNoDot) + lZone := strings.ToLower(zoneNoDot) + + // Already qualified for this zone (or equals zone itself). + if lKey == lZone || strings.HasSuffix(lKey, "."+lZone) { + if hasTrailingDot { + return keyNoDot + "." + } + return keyNoDot + } + + qualified := keyNoDot + "." + zoneNoDot + if hasTrailingDot { + return qualified + "." + } + return qualified +} + +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys/finalizers,verbs=update +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszoneclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := logf.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name) + logger.Info("dnszonetsigkey powerdns reconcile start") + + var tk dnsv1alpha1.DNSZoneTSIGKey + if err := r.Get(ctx, req.NamespacedName, &tk); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Deletion path: delete from PDNS, then drop finalizer. + if !tk.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(&tk, dnsZoneTSIGKeyPowerDNSFinalizer) { + pdnsCli := r.PDNS + if pdnsCli == nil { + return ctrl.Result{}, fmt.Errorf("pdns client is nil (SetupWithManager not called?)") + } + // Best-effort cleanup by ID. + if tk.Status.TSIGKeyName != "" { + if err := pdnsCli.DeleteTSIGKey(ctx, tk.Status.TSIGKeyName); err != nil { + logger.Error(err, "failed to delete PDNS TSIG key by id; will retry", "id", tk.Status.TSIGKeyName) + return ctrl.Result{}, err + } + } else { + // If we don't have a provider ID, we can't safely delete an external key. + // This commonly means the key was never successfully programmed. + logger.Info("skipping PDNS TSIG key delete (missing status.tsigKeyName)") + } + + base := tk.DeepCopy() + controllerutil.RemoveFinalizer(&tk, dnsZoneTSIGKeyPowerDNSFinalizer) + if err := r.Patch(ctx, &tk, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + // Ensure finalizer while active. + if !controllerutil.ContainsFinalizer(&tk, dnsZoneTSIGKeyPowerDNSFinalizer) { + base := tk.DeepCopy() + controllerutil.AddFinalizer(&tk, dnsZoneTSIGKeyPowerDNSFinalizer) + if err := r.Patch(ctx, &tk, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Resolve zone + class and ensure this DNSZoneTSIGKey is for a PowerDNS zone. + zone, ok, err := r.resolveZone(ctx, &tk) + if err != nil { + return ctrl.Result{}, err + } + if !ok { + // dependency missing or wrong controller; status already updated + return ctrl.Result{}, nil + } + _, ok, err = r.resolveZoneClass(ctx, &tk, zone) + if err != nil { + return ctrl.Result{}, err + } + if !ok { + // dependency missing or wrong controller; status already updated + return ctrl.Result{}, nil + } + + // Ensure the DNSZone is an owner of this DNSZoneTSIGKey so GC cascades on zone deletion. + if !metav1.IsControlledBy(&tk, zone) { + base := tk.DeepCopy() + if err := controllerutil.SetControllerReference(zone, &tk, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Patch(ctx, &tk, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Determine effective algorithm. + alg := tk.Spec.Algorithm + if alg == "" { + alg = dnsv1alpha1.TSIGAlgorithmHMACMD5 + } + + // Resolve key material from Secret (BYO) or generated secret. + secretName, keyMaterial, ok, err := r.resolveKeyMaterial(ctx, &tk) + if err != nil { + return ctrl.Result{}, err + } + + // Update status.secretName if needed (even when the Secret is not present yet). + // This ensures Secret watch events can enqueue the owning DNSZoneTSIGKey. + if secretName != "" && tk.Status.SecretName != secretName { + base := tk.DeepCopy() + tk.Status.SecretName = secretName + if err := r.Status().Patch(ctx, &tk, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + // re-fetch not required; continue + } + if !ok { + // invalid secret schema etc; Accepted updated + return ctrl.Result{}, nil + } + + if err := r.setAcceptedCondition(ctx, &tk, metav1.ConditionTrue, ReasonAccepted, "Accepted for zone"); err != nil { + return ctrl.Result{}, err + } + + ensureName := qualifyTSIGKeyName(tk.Spec.KeyName, zone.Spec.DomainName) + created, pdnsErr := r.PDNS.EnsureTSIGKey(ctx, ensureName, string(alg), keyMaterial) + if pdnsErr != nil { + _ = r.setProgrammedCondition(ctx, &tk, metav1.ConditionFalse, ReasonPDNSError, pdnsErr.Error()) + return ctrl.Result{}, pdnsErr + } + + // Persist provider identifier (PowerDNS ID). This is 1:1 with the TSIG key name in PDNS, + // but we expose it under status.tsigKeyName to align with the upstream "wire name". + id := created.ID + if id != "" && tk.Status.TSIGKeyName != id { + base := tk.DeepCopy() + tk.Status.TSIGKeyName = id + if err := r.Status().Patch(ctx, &tk, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + } + + if err := r.setProgrammedCondition(ctx, &tk, metav1.ConditionTrue, ReasonProgrammed, "TSIG key programmed"); err != nil { + return ctrl.Result{}, err + } + + logger.Info("dnszonetsigkey powerdns reconcile complete") + return ctrl.Result{}, nil +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) resolveZone(ctx context.Context, tk *dnsv1alpha1.DNSZoneTSIGKey) (*dnsv1alpha1.DNSZone, bool, error) { + // Zone lookup + var zone dnsv1alpha1.DNSZone + if err := r.Get(ctx, client.ObjectKey{Namespace: tk.Namespace, Name: tk.Spec.DNSZoneRef.Name}, &zone); err != nil { + if apierrors.IsNotFound(err) { + if err := r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, + fmt.Sprintf("waiting for DNSZone %q", tk.Spec.DNSZoneRef.Name)); err != nil { + return nil, false, err + } + return nil, false, nil + } + return nil, false, err + } + if !zone.DeletionTimestamp.IsZero() { + if err := r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, + fmt.Sprintf("DNSZone %q is deleting", zone.Name)); err != nil { + return nil, false, err + } + return nil, false, nil + } + return &zone, true, nil +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) resolveZoneClass(ctx context.Context, tk *dnsv1alpha1.DNSZoneTSIGKey, zone *dnsv1alpha1.DNSZone) (*dnsv1alpha1.DNSZoneClass, bool, error) { + if zone.Spec.DNSZoneClassName == "" { + if err := r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, + fmt.Sprintf("DNSZone %q has no class yet", zone.Name)); err != nil { + return nil, false, err + } + return nil, false, nil + } + + // Class lookup + var zc dnsv1alpha1.DNSZoneClass + if err := r.Get(ctx, client.ObjectKey{Name: zone.Spec.DNSZoneClassName}, &zc); err != nil { + if apierrors.IsNotFound(err) { + if err := r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, + fmt.Sprintf("DNSZoneClass %q not found", zone.Spec.DNSZoneClassName)); err != nil { + return nil, false, err + } + return nil, false, nil + } + return nil, false, err + } + if zc.Spec.ControllerName != ControllerNamePowerDNS { + if err := r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, + fmt.Sprintf("DNSZoneClass controller %q is not %q", zc.Spec.ControllerName, ControllerNamePowerDNS)); err != nil { + return nil, false, err + } + return nil, false, nil + } + return &zc, true, nil +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) resolveKeyMaterial(ctx context.Context, tk *dnsv1alpha1.DNSZoneTSIGKey) (secretName string, keyMaterial string, ok bool, err error) { + // BYO secret: validate schema and do not mutate. + if tk.Spec.SecretRef != nil && tk.Spec.SecretRef.Name != "" { + var s corev1.Secret + if err := r.Get(ctx, client.ObjectKey{Namespace: tk.Namespace, Name: tk.Spec.SecretRef.Name}, &s); err != nil { + if apierrors.IsNotFound(err) { + _ = r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, fmt.Sprintf("waiting for Secret %q", tk.Spec.SecretRef.Name)) + return tk.Spec.SecretRef.Name, "", false, nil + } + return "", "", false, err + } + secB := s.Data["secret"] + if len(secB) == 0 { + _ = r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonInvalidSecret, "secret.data.secret must be non-empty") + return s.Name, "", false, nil + } + // PDNS expects base64 key material. Secret.data.secret is already stored base64-encoded + // by Kubernetes at the API layer, so the bytes we get here are the original raw secret bytes. + return s.Name, base64.StdEncoding.EncodeToString(secB), true, nil + } + + // Generated secret mode: the replicator is responsible for creating and replicating this secret. + // The downstream controller only consumes and validates it. + secretName = tk.Name + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{Namespace: tk.Namespace, Name: secretName}, &secret); err != nil { + if apierrors.IsNotFound(err) { + _ = r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonPending, fmt.Sprintf("waiting for Secret %q", secretName)) + return secretName, "", false, nil + } + return "", "", false, err + } + secB := secret.Data["secret"] + if len(secB) == 0 { + _ = r.setAcceptedCondition(ctx, tk, metav1.ConditionFalse, ReasonInvalidSecret, "secret.data.secret must be non-empty") + return secretName, "", false, nil + } + return secretName, base64.StdEncoding.EncodeToString(secB), true, nil +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) setAcceptedCondition(ctx context.Context, tk *dnsv1alpha1.DNSZoneTSIGKey, status metav1.ConditionStatus, reason, message string) error { + base := tk.DeepCopy() + cond := metav1.Condition{ + Type: CondAccepted, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: tk.Generation, + LastTransitionTime: metav1.NewTime(time.Now()), + } + if !apimeta.SetStatusCondition(&tk.Status.Conditions, cond) { + return nil + } + return r.Status().Patch(ctx, tk, client.MergeFrom(base)) +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) setProgrammedCondition(ctx context.Context, tk *dnsv1alpha1.DNSZoneTSIGKey, status metav1.ConditionStatus, reason, message string) error { + base := tk.DeepCopy() + cond := metav1.Condition{ + Type: CondProgrammed, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: tk.Generation, + LastTransitionTime: metav1.NewTime(time.Now()), + } + if !apimeta.SetStatusCondition(&tk.Status.Conditions, cond) { + return nil + } + return r.Status().Patch(ctx, tk, client.MergeFrom(base)) +} + +func (r *DNSZoneTSIGKeyPowerDNSReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Initialize PDNS client once at setup-time (unless injected, e.g. tests). + // This fails fast on bad env/config rather than failing on the first reconcile. + if r.PDNS == nil { + cli, err := pdnsclient.NewFromEnv() + if err != nil { + return fmt.Errorf("pdns client: %w", err) + } + r.PDNS = cli + } + + // index DNSZoneTSIGKey by dnsZoneRef for quick fan-out from a DNSZone event + if err := mgr.GetFieldIndexer().IndexField(context.Background(), + &dnsv1alpha1.DNSZoneTSIGKey{}, "spec.DNSZoneRef.Name", + func(obj client.Object) []string { + tk := obj.(*dnsv1alpha1.DNSZoneTSIGKey) + return []string{tk.Spec.DNSZoneRef.Name} + }, + ); err != nil { + return err + } + + // Index DNSZoneTSIGKey by the effective secret name stored in status.secretName. + // This is what the controller actually consumes (BYO secretRef or generated). + if err := mgr.GetFieldIndexer().IndexField(context.Background(), + &dnsv1alpha1.DNSZoneTSIGKey{}, "status.secretName", + func(obj client.Object) []string { + tk := obj.(*dnsv1alpha1.DNSZoneTSIGKey) + if tk.Status.SecretName != "" { + return []string{tk.Status.SecretName} + } + return nil + }, + ); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&dnsv1alpha1.DNSZoneTSIGKey{}). + // When a DNSZone changes, enqueue its DNSZoneTSIGKeys. + Watches( + &dnsv1alpha1.DNSZone{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request { + zone := obj.(*dnsv1alpha1.DNSZone) + var tks dnsv1alpha1.DNSZoneTSIGKeyList + _ = mgr.GetClient().List(ctx, &tks, client.InNamespace(zone.Namespace), client.MatchingFields{"spec.DNSZoneRef.Name": zone.Name}) + out := make([]ctrl.Request, 0, len(tks.Items)) + for i := range tks.Items { + out = append(out, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tks.Items[i])}) + } + return out + }), + ). + // When a BYO secret changes, enqueue the DNSZoneTSIGKeys referencing it. + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request { + sec := obj.(*corev1.Secret) + var tks dnsv1alpha1.DNSZoneTSIGKeyList + _ = mgr.GetClient().List(ctx, &tks, client.InNamespace(sec.Namespace), client.MatchingFields{"status.secretName": sec.Name}) + out := make([]ctrl.Request, 0, len(tks.Items)) + for i := range tks.Items { + out = append(out, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tks.Items[i])}) + } + return out + }), + ). + Named("dnszonetsigkey-powerdns"). + Complete(r) +} diff --git a/internal/controller/dnszonetsigkey_powerdns_controller_test.go b/internal/controller/dnszonetsigkey_powerdns_controller_test.go new file mode 100644 index 0000000..2dc830d --- /dev/null +++ b/internal/controller/dnszonetsigkey_powerdns_controller_test.go @@ -0,0 +1,227 @@ +package controller_test + +import ( + "context" + "testing" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/controller" + pdnsclient "go.miloapis.com/dns-operator/internal/pdns" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeDNSZoneTSIGPDNS struct { + ensureCalls []struct { + Name string + Algorithm string + Key string + } + deleteByIDCalls []string + + ensureResp pdnsclient.TSIGKey + ensureErr error +} + +func (f *fakeDNSZoneTSIGPDNS) EnsureTSIGKey(_ context.Context, name, algorithm, keyMaterial string) (pdnsclient.TSIGKey, error) { + f.ensureCalls = append(f.ensureCalls, struct { + Name string + Algorithm string + Key string + }{name, algorithm, keyMaterial}) + return f.ensureResp, f.ensureErr +} +func (f *fakeDNSZoneTSIGPDNS) DeleteTSIGKey(_ context.Context, id string) error { + f.deleteByIDCalls = append(f.deleteByIDCalls, id) + return nil +} + +func newDNSOnlySchemeTSIG(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatalf("add dns scheme: %v", err) + } + if err := corev1.AddToScheme(s); err != nil { + t.Fatalf("add core scheme: %v", err) + } + return s +} + +func TestDNSZoneTSIGKeyPowerDNS_ByoSecret_ValidatesAndPrograms(t *testing.T) { + t.Parallel() + + scheme := newDNSOnlySchemeTSIG(t) + zone, zc := newZoneAndClass("example-com") + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: ns}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "secret": []byte("supersecret"), + }, + } + + tk := &dnsv1alpha1.DNSZoneTSIGKey{ + ObjectMeta: metav1.ObjectMeta{Name: "xfr", Namespace: ns}, + Spec: dnsv1alpha1.DNSZoneTSIGKeySpec{ + DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}, + KeyName: "xfr", + Algorithm: dnsv1alpha1.TSIGAlgorithmHMACSHA256, + SecretRef: &corev1.LocalObjectReference{Name: secret.Name}, + }, + } + + wantPDNSName := "xfr.example.com" + pdns := &fakeDNSZoneTSIGPDNS{ensureResp: pdnsclient.TSIGKey{ID: wantPDNSName, Name: wantPDNSName, Algorithm: "hmac-sha256"}} + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&dnsv1alpha1.DNSZoneTSIGKey{}). + WithObjects(zone, zc, secret, tk). + Build() + + r := &controller.DNSZoneTSIGKeyPowerDNSReconciler{Client: c, Scheme: scheme, PDNS: pdns} + // Reconcile is multi-step (finalizer, ownerrefs, etc). Run a few times to converge. + for i := 0; i < 5; i++ { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tk)}) + if err != nil { + t.Fatalf("reconcile error: %v", err) + } + } + + var got dnsv1alpha1.DNSZoneTSIGKey + if err := c.Get(context.Background(), client.ObjectKeyFromObject(tk), &got); err != nil { + t.Fatalf("get: %v", err) + } + + if cond := apimeta.FindStatusCondition(got.Status.Conditions, controller.CondAccepted); cond == nil || cond.Status != metav1.ConditionTrue { + t.Fatalf("Accepted not true: %#v", got.Status.Conditions) + } + if cond := apimeta.FindStatusCondition(got.Status.Conditions, controller.CondProgrammed); cond == nil || cond.Status != metav1.ConditionTrue { + t.Fatalf("Programmed not true: %#v", got.Status.Conditions) + } + if got.Status.TSIGKeyName != wantPDNSName { + t.Fatalf("expected tsigKeyName=%q, got %q", wantPDNSName, got.Status.TSIGKeyName) + } + if got.Status.SecretName != secret.Name { + t.Fatalf("expected secretName=%q, got %q", secret.Name, got.Status.SecretName) + } + if len(pdns.ensureCalls) < 1 { + t.Fatalf("expected at least 1 ensure call, got %d", len(pdns.ensureCalls)) + } + if gotCall := pdns.ensureCalls[0].Name; gotCall != wantPDNSName { + t.Fatalf("expected EnsureTSIGKey name=%q, got %q", wantPDNSName, gotCall) + } +} + +func TestDNSZoneTSIGKeyPowerDNS_ByoSecret_InvalidSchemaRejected(t *testing.T) { + t.Parallel() + + scheme := newDNSOnlySchemeTSIG(t) + zone, zc := newZoneAndClass("example-com") + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: ns}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + // missing required secret key + }, + } + + tk := &dnsv1alpha1.DNSZoneTSIGKey{ + ObjectMeta: metav1.ObjectMeta{Name: "xfr", Namespace: ns}, + Spec: dnsv1alpha1.DNSZoneTSIGKeySpec{ + DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}, + KeyName: "datum-example-com-xfr", + Algorithm: dnsv1alpha1.TSIGAlgorithmHMACSHA256, + SecretRef: &corev1.LocalObjectReference{Name: secret.Name}, + }, + } + + pdns := &fakeDNSZoneTSIGPDNS{ensureResp: pdnsclient.TSIGKey{ID: "pdns-id"}} + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&dnsv1alpha1.DNSZoneTSIGKey{}). + WithObjects(zone, zc, secret, tk). + Build() + + r := &controller.DNSZoneTSIGKeyPowerDNSReconciler{Client: c, Scheme: scheme, PDNS: pdns} + for i := 0; i < 5; i++ { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tk)}) + if err != nil { + t.Fatalf("reconcile error: %v", err) + } + } + + var got dnsv1alpha1.DNSZoneTSIGKey + _ = c.Get(context.Background(), client.ObjectKeyFromObject(tk), &got) + cond := apimeta.FindStatusCondition(got.Status.Conditions, controller.CondAccepted) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != controller.ReasonInvalidSecret { + t.Fatalf("expected Accepted=False InvalidSecret, got %#v", got.Status.Conditions) + } + if len(pdns.ensureCalls) != 0 { + t.Fatalf("expected no PDNS ensure calls when secret invalid") + } +} + +func TestDNSZoneTSIGKeyPowerDNS_GeneratesSecretAndPrograms(t *testing.T) { + t.Parallel() + + scheme := newDNSOnlySchemeTSIG(t) + zone, zc := newZoneAndClass("example-com") + + tk := &dnsv1alpha1.DNSZoneTSIGKey{ + ObjectMeta: metav1.ObjectMeta{Name: "xfr", Namespace: ns}, + Spec: dnsv1alpha1.DNSZoneTSIGKeySpec{ + DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}, + KeyName: "xfr", + Algorithm: dnsv1alpha1.TSIGAlgorithmHMACSHA256, + // SecretRef omitted => generated + }, + } + + // In generated-secret mode, the replicator is responsible for creating the Secret. + secretName := tk.Name + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "secret": []byte("supersecret"), + }, + } + + wantPDNSName := "xfr.example.com" + pdns := &fakeDNSZoneTSIGPDNS{ensureResp: pdnsclient.TSIGKey{ID: wantPDNSName, Name: wantPDNSName, Algorithm: "hmac-sha256"}} + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&dnsv1alpha1.DNSZoneTSIGKey{}). + WithObjects(zone, zc, tk, secret). + Build() + + r := &controller.DNSZoneTSIGKeyPowerDNSReconciler{Client: c, Scheme: scheme, PDNS: pdns} + for i := 0; i < 5; i++ { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tk)}) + if err != nil { + t.Fatalf("reconcile error: %v", err) + } + } + + var got dnsv1alpha1.DNSZoneTSIGKey + if err := c.Get(context.Background(), client.ObjectKeyFromObject(tk), &got); err != nil { + t.Fatalf("get: %v", err) + } + if got.Status.SecretName != secretName { + t.Fatalf("expected secretName=%q, got %q", secretName, got.Status.SecretName) + } + if len(pdns.ensureCalls) < 1 { + t.Fatalf("expected PDNS ensure called") + } + if gotCall := pdns.ensureCalls[0].Name; gotCall != wantPDNSName { + t.Fatalf("expected EnsureTSIGKey name=%q, got %q", wantPDNSName, gotCall) + } +} diff --git a/internal/controller/dnszonetsigkey_replicator_controller.go b/internal/controller/dnszonetsigkey_replicator_controller.go new file mode 100644 index 0000000..ce7efc7 --- /dev/null +++ b/internal/controller/dnszonetsigkey_replicator_controller.go @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "crypto/rand" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + mcsource "sigs.k8s.io/multicluster-runtime/pkg/source" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/downstreamclient" + corev1 "k8s.io/api/core/v1" +) + +const dnsZoneTSIGKeyFinalizer = "dns.networking.miloapis.com/finalize-dnszonetsigkey" + +// DNSZoneTSIGKeyReplicator mirrors DNSZoneTSIGKey resources into the downstream cluster and reflects downstream status back upstream. +type DNSZoneTSIGKeyReplicator struct { + DownstreamClient client.Client + + mgr mcmanager.Manager +} + +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonetsigkeys/finalizers,verbs=update +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch + +func (r *DNSZoneTSIGKeyReplicator) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + lg := log.FromContext(ctx).WithValues("cluster", req.ClusterName, "namespace", req.Namespace, "name", req.Name) + ctx = log.IntoContext(ctx, lg) + lg.Info("reconcile start") + + upstreamCluster, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + + var upstream dnsv1alpha1.DNSZoneTSIGKey + if err := upstreamCluster.GetClient().Get(ctx, req.NamespacedName, &upstream); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + strategy := downstreamclient.NewMappedNamespaceResourceStrategy(req.ClusterName, upstreamCluster.GetClient(), r.DownstreamClient) + + // Ensure upstream finalizer (non-deletion path) + if upstream.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(&upstream, dnsZoneTSIGKeyFinalizer) { + base := upstream.DeepCopy() + controllerutil.AddFinalizer(&upstream, dnsZoneTSIGKeyFinalizer) + if err := upstreamCluster.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Deletion path: delete downstream shadow first then remove finalizer. + if !upstream.DeletionTimestamp.IsZero() { + done, err := r.handleDeletion(ctx, upstreamCluster.GetClient(), strategy, &upstream) + if err != nil { + return ctrl.Result{}, err + } + if !done { + return ctrl.Result{}, nil + } + return ctrl.Result{}, nil + } + + // Gate on referenced DNSZone early and update status when missing + var zone dnsv1alpha1.DNSZone + if err := upstreamCluster.GetClient().Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: upstream.Spec.DNSZoneRef.Name}, &zone); err != nil { + if apierrors.IsNotFound(err) { + zoneMsg := fmt.Sprintf("DNSZone %q not found", upstream.Spec.DNSZoneRef.Name) + if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{ + Type: CondAccepted, + Status: metav1.ConditionFalse, + Reason: ReasonPending, + Message: zoneMsg, + ObservedGeneration: upstream.Generation, + LastTransitionTime: metav1.Now(), + }) { + base := upstream.DeepCopy() + if err := upstreamCluster.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // If the zone is being deleted, do not program downstream key + if !zone.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + // Ensure OwnerReference to upstream DNSZone (same ns) + if !metav1.IsControlledBy(&upstream, &zone) { + base := upstream.DeepCopy() + if err := controllerutil.SetControllerReference(&zone, &upstream, upstreamCluster.GetScheme()); err != nil { + return ctrl.Result{}, err + } + if err := upstreamCluster.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Ensure downstream shadow DNSZoneTSIGKey mirrors upstream spec. + if err := r.ensureDownstreamDNSZoneTSIGKey(ctx, strategy, &upstream); err != nil { + return ctrl.Result{}, err + } + + // Ensure Secret is present upstream and replicated to downstream so PowerDNS can consume it. + if err := r.ensureSecretReplication(ctx, upstreamCluster.GetClient(), strategy, &upstream); err != nil { + return ctrl.Result{}, err + } + + // Mirror downstream status when the shadow exists. + md, mdErr := strategy.ObjectMetaFromUpstreamObject(ctx, &upstream) + if mdErr != nil { + return ctrl.Result{}, mdErr + } + var shadow dnsv1alpha1.DNSZoneTSIGKey + if err := r.DownstreamClient.Get(ctx, types.NamespacedName{Namespace: md.Namespace, Name: md.Name}, &shadow); err != nil { + return ctrl.Result{}, err + } + if err := r.updateStatus(ctx, upstreamCluster.GetClient(), &upstream, shadow.Status.DeepCopy()); err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil +} + +func (r *DNSZoneTSIGKeyReplicator) handleDeletion(ctx context.Context, c client.Client, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZoneTSIGKey) (done bool, err error) { + if !controllerutil.ContainsFinalizer(upstream, dnsZoneTSIGKeyFinalizer) { + return true, nil + } + + // Best-effort explicit deletes. + secretName := upstream.Name + generatedSecret := true + if upstream.Spec.SecretRef != nil && upstream.Spec.SecretRef.Name != "" { + generatedSecret = false + secretName = upstream.Spec.SecretRef.Name + } + + md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream) + if err != nil { + return false, err + } + + // Only delete the Secret in generated-secret mode. In BYO mode, multiple + // DNSZoneTSIGKeys can reference the same Secret name, so deleting it here + // would break other keys. + if generatedSecret { + var secret corev1.Secret + secret.SetNamespace(md.Namespace) + secret.SetName(secretName) + if err := r.DownstreamClient.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) { + return false, err + } + } + + // Then delete the downstream shadow. + var shadow dnsv1alpha1.DNSZoneTSIGKey + shadow.SetNamespace(md.Namespace) + shadow.SetName(md.Name) + if err := r.DownstreamClient.Delete(ctx, &shadow); err != nil && !apierrors.IsNotFound(err) { + return false, err + } + + // Finally delete the anchor ConfigMap for this upstream object. This drives GC for + // downstream artifacts (shadow + replicated Secret) that are owned via the anchor. + if err := strategy.DeleteAnchorForObject(ctx, upstream); err != nil { + return false, err + } + + base := upstream.DeepCopy() + controllerutil.RemoveFinalizer(upstream, dnsZoneTSIGKeyFinalizer) + if err := c.Patch(ctx, upstream, client.MergeFrom(base)); err != nil { + return false, err + } + return true, nil +} + +func (r *DNSZoneTSIGKeyReplicator) ensureDownstreamDNSZoneTSIGKey(ctx context.Context, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZoneTSIGKey) error { + md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream) + if err != nil { + return err + } + + shadow := dnsv1alpha1.DNSZoneTSIGKey{} + shadow.SetNamespace(md.Namespace) + shadow.SetName(md.Name) + + // Ensure we create in the correct mapped namespace (and that it exists) by using the strategy client. + dsClient := strategy.GetClient() + + res, cErr := controllerutil.CreateOrPatch(ctx, dsClient, &shadow, func() error { + shadow.Labels = md.Labels + + if !equality.Semantic.DeepEqual(shadow.Spec, upstream.Spec) { + shadow.Spec = upstream.Spec + } + + // Set owner reference using the mapped-namespace strategy (anchor-based). + // Anchor deletion is handled in the upstream deletion path (handleDeletion). + return strategy.SetControllerReference(ctx, upstream, &shadow) + }) + if cErr != nil { + return cErr + } + log.FromContext(ctx).Info("ensured downstream DNSZoneTSIGKey", "operation", res, "namespace", shadow.Namespace, "name", shadow.Name) + return nil +} + +func (r *DNSZoneTSIGKeyReplicator) updateStatus(ctx context.Context, c client.Client, upstream *dnsv1alpha1.DNSZoneTSIGKey, downstreamStatus *dnsv1alpha1.DNSZoneTSIGKeyStatus) error { + if downstreamStatus == nil { + return nil + } + if equality.Semantic.DeepEqual(upstream.Status, *downstreamStatus) { + return nil + } + base := upstream.DeepCopy() + upstream.Status = *downstreamStatus + return c.Status().Patch(ctx, upstream, client.MergeFrom(base)) +} + +func (r *DNSZoneTSIGKeyReplicator) ensureSecretReplication(ctx context.Context, upstreamClient client.Client, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZoneTSIGKey) error { + // Determine the source secret name. + secretName := upstream.Name + if upstream.Spec.SecretRef != nil && upstream.Spec.SecretRef.Name != "" { + secretName = upstream.Spec.SecretRef.Name + } + + // Determine algorithm (default if omitted). + alg := upstream.Spec.Algorithm + if alg == "" { + alg = dnsv1alpha1.TSIGAlgorithmHMACMD5 + } + + // Ensure upstream secret exists (create only in generated-secret mode). + var src corev1.Secret + if err := upstreamClient.Get(ctx, client.ObjectKey{Namespace: upstream.Namespace, Name: secretName}, &src); err != nil { + if apierrors.IsNotFound(err) { + if upstream.Spec.SecretRef != nil && upstream.Spec.SecretRef.Name != "" { + // BYO secret not found yet; wait. + return nil + } + + // Generated mode: create the secret upstream. + src = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: upstream.Namespace}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + _, err := controllerutil.CreateOrPatch(ctx, upstreamClient, &src, func() error { + if src.Type == "" { + src.Type = corev1.SecretTypeOpaque + } + if src.Data == nil { + src.Data = map[string][]byte{} + } + if len(src.Data["secret"]) == 0 { + raw := make([]byte, tsigKeySecretLen(alg)) + if _, err := rand.Read(raw); err != nil { + return err + } + // Store raw secret bytes. (PowerDNS expects base64, but that is derived at reconciliation time.) + src.Data["secret"] = raw + } + return controllerutil.SetControllerReference(upstream, &src, upstreamClient.Scheme()) + }) + return err + } + return err + } + + // Ensure downstream secret mirrors upstream secret data. + md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream) + if err != nil { + return err + } + dsClient := strategy.GetClient() // ensures downstream namespace exists on Create + + // Fetch downstream DNSZoneTSIGKey shadow for owner reference (GC in downstream). + var shadow dnsv1alpha1.DNSZoneTSIGKey + if err := r.DownstreamClient.Get(ctx, types.NamespacedName{Namespace: md.Namespace, Name: md.Name}, &shadow); err != nil { + return err + } + + dst := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: md.Namespace, Name: secretName}} + _, err = controllerutil.CreateOrPatch(ctx, dsClient, dst, func() error { + if dst.Type == "" { + dst.Type = corev1.SecretTypeOpaque + } + if dst.Data == nil { + dst.Data = map[string][]byte{} + } + // Copy secret bytes exactly. + dst.Data["secret"] = append([]byte(nil), src.Data["secret"]...) + + // Set owner reference using the mapped-namespace strategy (anchor-based). + // Anchor deletion is handled in the upstream deletion path (handleDeletion). + if err := strategy.SetControllerReference(ctx, upstream, dst); err != nil { + return err + } + + return nil + }) + return err +} + +func (r *DNSZoneTSIGKeyReplicator) SetupWithManager(mgr mcmanager.Manager, downstreamCl cluster.Cluster) error { + r.mgr = mgr + + b := builder.ControllerManagedBy(mgr) + b = b.For(&dnsv1alpha1.DNSZoneTSIGKey{}) + + src := mcsource.TypedKind( + &dnsv1alpha1.DNSZoneTSIGKey{}, + downstreamclient.TypedEnqueueRequestForUpstreamOwner[*dnsv1alpha1.DNSZoneTSIGKey](&dnsv1alpha1.DNSZoneTSIGKey{}), + ) + clusterSrc, err := src.ForCluster("", downstreamCl) + if err != nil { + return fmt.Errorf("failed to build downstream watch for %s: %w", dnsv1alpha1.GroupVersion.WithKind("DNSZoneTSIGKey").String(), err) + } + b = b.WatchesRawSource(clusterSrc) + + // Also watch downstream Secrets (generated or BYO replicated) to ensure upstream DNSZoneTSIGKey reconcile + // happens when secret material changes (or is first created). + secretSrc := mcsource.TypedKind( + &corev1.Secret{}, + downstreamclient.TypedEnqueueRequestForUpstreamOwner[*corev1.Secret](&dnsv1alpha1.DNSZoneTSIGKey{}), + ) + secretClusterSrc, err := secretSrc.ForCluster("", downstreamCl) + if err != nil { + return fmt.Errorf("failed to build downstream watch for %s: %w", corev1.SchemeGroupVersion.WithKind("Secret").String(), err) + } + b = b.WatchesRawSource(secretClusterSrc) + + return b.Named("dnszonetsigkey-replicator").Complete(r) +} + +func tsigKeySecretLen(alg dnsv1alpha1.TSIGAlgorithm) int { + // Align with HMAC guidance: key length == hash output size. + // (RFC 2845 for TSIG; RFC 4635 adds the SHA2-based TSIG algorithms.) + switch alg { + case dnsv1alpha1.TSIGAlgorithmHMACMD5: + return 16 + case dnsv1alpha1.TSIGAlgorithmHMACSHA1: + return 20 + case dnsv1alpha1.TSIGAlgorithmHMACSHA224: + return 28 + case dnsv1alpha1.TSIGAlgorithmHMACSHA256: + return 32 + case dnsv1alpha1.TSIGAlgorithmHMACSHA384: + return 48 + case dnsv1alpha1.TSIGAlgorithmHMACSHA512: + return 64 + default: + // Unknown/empty algorithm: keep existing behavior (safe default). + return 32 + } +} diff --git a/internal/controller/dnszonetsigkey_replicator_controller_test.go b/internal/controller/dnszonetsigkey_replicator_controller_test.go new file mode 100644 index 0000000..014648d --- /dev/null +++ b/internal/controller/dnszonetsigkey_replicator_controller_test.go @@ -0,0 +1,33 @@ +package controller + +import ( + "testing" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" +) + +func TestTsigKeySecretLen_EqualsHashOutputSize(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + alg dnsv1alpha1.TSIGAlgorithm + want int + }{ + {"hmac-md5", dnsv1alpha1.TSIGAlgorithmHMACMD5, 16}, + {"hmac-sha1", dnsv1alpha1.TSIGAlgorithmHMACSHA1, 20}, + {"hmac-sha224", dnsv1alpha1.TSIGAlgorithmHMACSHA224, 28}, + {"hmac-sha256", dnsv1alpha1.TSIGAlgorithmHMACSHA256, 32}, + {"hmac-sha384", dnsv1alpha1.TSIGAlgorithmHMACSHA384, 48}, + {"hmac-sha512", dnsv1alpha1.TSIGAlgorithmHMACSHA512, 64}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tsigKeySecretLen(tc.alg); got != tc.want { + t.Fatalf("tsigKeySecretLen(%q)=%d, want %d", tc.alg, got, tc.want) + } + }) + } +} diff --git a/internal/pdns/client.go b/internal/pdns/client.go index b6acab0..aaf0067 100644 --- a/internal/pdns/client.go +++ b/internal/pdns/client.go @@ -45,6 +45,16 @@ func NewClient(baseURL, apiKey string) *Client { } } +// TSIGKey represents a PowerDNS TSIGKey object. +// Note: the "key" field is typically empty when listing keys. +type TSIGKey struct { + Name string `json:"name"` + ID string `json:"id"` + Algorithm string `json:"algorithm"` + Key string `json:"key,omitempty"` + Type string `json:"type,omitempty"` +} + type pdnsAPIError struct { Status int Body string @@ -57,16 +67,15 @@ func (e *pdnsAPIError) Error() string { return fmt.Sprintf("error: status %d", e.Status) } -func readRespBody(resp *http.Response, max int64) string { +const maxErrorBodyBytes int64 = 64 << 10 // 64 KiB + +func readRespBody(resp *http.Response) string { if resp == nil || resp.Body == nil { return "" } defer func() { _ = resp.Body.Close() }() - // don't blow up logs; cap at e.g. 16KB - if max <= 0 { - max = 16 << 10 // 16 KiB - } - b, _ := io.ReadAll(io.LimitReader(resp.Body, max)) + // don't blow up logs; cap response bodies + b, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) return strings.TrimSpace(string(b)) } @@ -138,6 +147,135 @@ func (c *Client) GetZone(ctx context.Context, zone string) (string, error) { return zoneResponse.Name, nil } +// ListTSIGKeys lists all TSIG keys in the server. +func (c *Client) ListTSIGKeys(ctx context.Context) ([]TSIGKey, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/servers/localhost/tsigkeys", nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", c.APIKey) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + errBody := readRespBody(resp) // closes Body + return nil, &pdnsAPIError{Status: resp.StatusCode, Body: errBody} + } + defer func() { _ = resp.Body.Close() }() + var out []TSIGKey + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +// CreateTSIGKey creates a TSIG key in PowerDNS. keyMaterial may be empty to let the server generate it. +func (c *Client) CreateTSIGKey(ctx context.Context, name, algorithm, keyMaterial string) (TSIGKey, error) { + payload := map[string]string{ + "name": name, + "algorithm": algorithm, + } + if keyMaterial != "" { + payload["key"] = keyMaterial + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/v1/servers/localhost/tsigkeys", bytes.NewReader(body)) + if err != nil { + return TSIGKey{}, err + } + req.Header.Set("X-API-Key", c.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return TSIGKey{}, err + } + if resp.StatusCode/100 != 2 { + errBody := readRespBody(resp) // closes Body + return TSIGKey{}, &pdnsAPIError{Status: resp.StatusCode, Body: errBody} + } + defer func() { _ = resp.Body.Close() }() + var out TSIGKey + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return TSIGKey{}, err + } + return out, nil +} + +// FindTSIGKeyByName finds a TSIG key by name, returning nil when not found. +func (c *Client) FindTSIGKeyByName(ctx context.Context, name string) (*TSIGKey, error) { + keys, err := c.ListTSIGKeys(ctx) + if err != nil { + return nil, err + } + for i := range keys { + if keys[i].Name == name { + k := keys[i] + return &k, nil + } + } + return nil, nil +} + +// EnsureTSIGKey ensures a TSIG key exists with the given name/algorithm. If a key exists +// with a different algorithm, it will be deleted and recreated. +func (c *Client) EnsureTSIGKey(ctx context.Context, name, algorithm, keyMaterial string) (TSIGKey, error) { + existing, err := c.FindTSIGKeyByName(ctx, name) + if err != nil { + return TSIGKey{}, err + } + if existing != nil { + if existing.Algorithm == algorithm { + return *existing, nil + } + // Recreate when algorithm differs. + if existing.ID != "" { + if err := c.DeleteTSIGKey(ctx, existing.ID); err != nil { + return TSIGKey{}, err + } + } + } + return c.CreateTSIGKey(ctx, name, algorithm, keyMaterial) +} + +// DeleteTSIGKey deletes a TSIG key by ID. +func (c *Client) DeleteTSIGKey(ctx context.Context, id string) error { + if id == "" { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+"/api/v1/servers/localhost/tsigkeys/"+id, nil) + if err != nil { + return err + } + req.Header.Set("X-API-Key", c.APIKey) + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + if resp.StatusCode == http.StatusNotFound { + _ = resp.Body.Close() + return nil + } + if resp.StatusCode/100 != 2 { + errBody := readRespBody(resp) // closes Body + return &pdnsAPIError{Status: resp.StatusCode, Body: errBody} + } + _ = resp.Body.Close() + return nil +} + +// DeleteTSIGKeyByName deletes a TSIG key by name (no-op if not found). +func (c *Client) DeleteTSIGKeyByName(ctx context.Context, name string) error { + existing, err := c.FindTSIGKeyByName(ctx, name) + if err != nil { + return err + } + if existing == nil { + return nil + } + return c.DeleteTSIGKey(ctx, existing.ID) +} + // in package pdns (same file as CreateZone/GetZone) func (c *Client) DeleteZone(ctx context.Context, zone string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, @@ -397,7 +535,7 @@ func (c *Client) applyRRSetPatch(ctx context.Context, zone string, patch []rrset return err } if resp.StatusCode/100 != 2 { - errBody := readRespBody(resp, 64<<10) // closes Body + errBody := readRespBody(resp) // closes Body return &pdnsAPIError{Status: resp.StatusCode, Body: errBody} } _ = resp.Body.Close() diff --git a/internal/pdns/pdns_integration_test.go b/internal/pdns/pdns_integration_test.go index c7b344f..c193f8d 100644 --- a/internal/pdns/pdns_integration_test.go +++ b/internal/pdns/pdns_integration_test.go @@ -1,8 +1,13 @@ package pdns import ( + "bytes" "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "net/http" "os" "path/filepath" "sort" @@ -40,13 +45,15 @@ func writePDNSAuthWithSQLite(t *testing.T, dir, apiKey string) { } } -func startPDNS(t *testing.T, apiKey string) (baseURL string, terminate func()) { +const integrationAPIKey = "itest-key" + +func startPDNS(t *testing.T) (baseURL string, terminate func()) { t.Helper() ctx := context.Background() cfgDir := t.TempDir() dataDir := t.TempDir() - writePDNSAuthWithSQLite(t, cfgDir, apiKey) + writePDNSAuthWithSQLite(t, cfgDir, integrationAPIKey) // Use an official-ish PDNS authoritative image that reads /etc/powerdns/pdns.conf. // You can pin a specific version if you prefer, e.g. powerdns/pdns-auth-46:latest @@ -71,7 +78,7 @@ func startPDNS(t *testing.T, apiKey string) (baseURL string, terminate func()) { }, WaitingFor: wait.ForHTTP("/api/v1/servers/localhost"). WithPort("8081/tcp"). - WithHeaders(map[string]string{"X-API-Key": apiKey}). + WithHeaders(map[string]string{"X-API-Key": integrationAPIKey}). WithStartupTimeout(2 * time.Minute), }, Started: true, @@ -99,11 +106,10 @@ func startPDNS(t *testing.T, apiKey string) (baseURL string, terminate func()) { func TestPDNS_EndToEnd_AllTypes(t *testing.T) { // No t.Parallel(): we’re booting a container. - const apiKey = "itest-key" - baseURL, stop := startPDNS(t, apiKey) + baseURL, stop := startPDNS(t) defer stop() - client := NewClient(baseURL, apiKey) + client := NewClient(baseURL, integrationAPIKey) zone := "example.test" // Create the zone with some NS via the API create call @@ -323,11 +329,10 @@ func TestPDNS_EndToEnd_AllTypes(t *testing.T) { func TestPDNS_ApplyRecordSetAuthoritative_CleansRemovedOwners(t *testing.T) { // No t.Parallel(): container + real PDNS. - const apiKey = "itest-key" - baseURL, stop := startPDNS(t, apiKey) + baseURL, stop := startPDNS(t) defer stop() - client := NewClient(baseURL, apiKey) + client := NewClient(baseURL, integrationAPIKey) ctx := context.Background() zone := "cleanup.test" @@ -428,3 +433,174 @@ func TestPDNS_ApplyRecordSetAuthoritative_CleansRemovedOwners(t *testing.T) { t.Fatalf("SOA rrset count changed: before=%d after=%d", soaBefore, got) } } + +func TestPDNS_TSIGKey_CreateWithSuppliedKey(t *testing.T) { + // No t.Parallel(): container + real PDNS. + baseURL, stop := startPDNS(t) + defer stop() + + client := NewClient(baseURL, integrationAPIKey) + ctx := context.Background() + + keyMaterial := base64.StdEncoding.EncodeToString([]byte("supersecret")) + created, err := client.CreateTSIGKey(ctx, "mytsigkey", "hmac-sha256", keyMaterial) + if err != nil { + t.Fatalf("CreateTSIGKey: %v", err) + } + if created.ID == "" { + t.Fatalf("expected non-empty id, got %#v", created) + } + if created.Name != "mytsigkey" { + t.Fatalf("expected name=mytsigkey, got %#v", created) + } + if created.Algorithm != "hmac-sha256" { + t.Fatalf("expected algorithm=hmac-sha256, got %#v", created) + } +} + +func TestPDNS_TSIGKey_CreateAndDeleteByID(t *testing.T) { + // No t.Parallel(): container + real PDNS. + baseURL, stop := startPDNS(t) + defer stop() + + client := NewClient(baseURL, integrationAPIKey) + ctx := context.Background() + + keyMaterial := base64.StdEncoding.EncodeToString([]byte("supersecret")) + created, err := client.CreateTSIGKey(ctx, "mytsigkey-delete", "hmac-sha256", keyMaterial) + if err != nil { + t.Fatalf("CreateTSIGKey: %v", err) + } + if created.ID == "" { + t.Fatalf("expected non-empty id: %#v", created) + } + + if err := client.DeleteTSIGKey(ctx, created.ID); err != nil { + t.Fatalf("DeleteTSIGKey: %v", err) + } + + found, err := client.FindTSIGKeyByName(ctx, "mytsigkey-delete") + if err != nil { + t.Fatalf("FindTSIGKeyByName: %v", err) + } + if found != nil { + t.Fatalf("expected key deleted, found %#v", found) + } +} + +func TestPDNS_TSIGKey_IDHasTrailingDot_AndDuplicateNameIsRejected(t *testing.T) { + // No t.Parallel(): container + real PDNS. + baseURL, stop := startPDNS(t) + defer stop() + + client := NewClient(baseURL, integrationAPIKey) + ctx := context.Background() + + // Use the same provider-visible name twice. + const name = "mytsigkey-duplicate" + + k1, err := client.CreateTSIGKey(ctx, name, "hmac-sha256", base64.StdEncoding.EncodeToString([]byte("supersecret-1"))) + if err != nil { + t.Fatalf("CreateTSIGKey #1: %v", err) + } + t.Cleanup(func() { _ = client.DeleteTSIGKey(context.Background(), k1.ID) }) + + if k1.Name != name { + t.Fatalf("expected name=%q, got %#v", name, k1) + } + if k1.ID == "" { + t.Fatalf("expected non-empty id, got %#v", k1) + } + if !strings.HasSuffix(k1.ID, ".") { + t.Fatalf("expected PDNS TSIGKey ID to have trailing dot, got %q", k1.ID) + } + + // PowerDNS enforces name uniqueness; creating a second key with the same name should be rejected. + _, err = client.CreateTSIGKey(ctx, name, "hmac-sha256", base64.StdEncoding.EncodeToString([]byte("supersecret-2"))) + if err == nil { + t.Fatalf("expected CreateTSIGKey #2 to fail with conflict, but got nil error") + } + var apiErr *pdnsAPIError + if !errors.As(err, &apiErr) || apiErr.Status != 409 { + t.Fatalf("expected 409 Conflict from CreateTSIGKey #2, got err=%T %v", err, err) + } +} + +func TestPDNS_TSIGKey_NameWithTrailingDot_IDHasSingleTrailingDot(t *testing.T) { + // No t.Parallel(): container + real PDNS. + baseURL, stop := startPDNS(t) + defer stop() + + client := NewClient(baseURL, integrationAPIKey) + ctx := context.Background() + + const nameWithDot = "mytsigkey-trailing-dot." + created, err := client.CreateTSIGKey(ctx, nameWithDot, "hmac-sha256", base64.StdEncoding.EncodeToString([]byte("supersecret"))) + if err != nil { + t.Fatalf("CreateTSIGKey: %v", err) + } + t.Cleanup(func() { _ = client.DeleteTSIGKey(context.Background(), created.ID) }) + + // PowerDNS normalizes TSIGKey.Name by stripping a trailing dot. + wantName := strings.TrimSuffix(nameWithDot, ".") + if created.Name != wantName { + t.Fatalf("expected created.Name=%q, got %#v", wantName, created) + } + + if created.ID == "" { + t.Fatalf("expected non-empty id, got %#v", created) + } + if !strings.HasSuffix(created.ID, ".") { + t.Fatalf("expected id to have trailing dot, got %q", created.ID) + } + if strings.HasSuffix(created.ID, "..") { + t.Fatalf("expected id to not end with two trailing dots, got %q", created.ID) + } + if created.ID != created.Name+"." { + t.Fatalf("expected id to equal name + trailing dot, got name=%q id=%q", created.Name, created.ID) + } +} + +func TestPDNS_TSIGKey_DuplicateNameEvenWithIDFieldIsRejected(t *testing.T) { + // No t.Parallel(): container + real PDNS. + baseURL, stop := startPDNS(t) + defer stop() + + client := NewClient(baseURL, integrationAPIKey) + ctx := context.Background() + + const name = "mytsigkey-dup-with-id-field" + + created, err := client.CreateTSIGKey(ctx, name, "hmac-sha256", base64.StdEncoding.EncodeToString([]byte("supersecret-1"))) + if err != nil { + t.Fatalf("CreateTSIGKey #1: %v", err) + } + t.Cleanup(func() { _ = client.DeleteTSIGKey(context.Background(), created.ID) }) + + // Try to create the same name again, but include an explicit ID field in the request body. + // PowerDNS should still reject duplicate names. + payload := map[string]string{ + "name": name, + "algorithm": "hmac-sha256", + "key": base64.StdEncoding.EncodeToString([]byte("supersecret-2")), + "id": "some-explicit-id-that-should-not-matter.", + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BaseURL+"/api/v1/servers/localhost/tsigkeys", bytes.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("X-API-Key", client.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.HTTP.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 Conflict for duplicate name even with id field, got %d", resp.StatusCode) + } +} diff --git a/internal/pdns/pdns_test.go b/internal/pdns/pdns_test.go index b08c838..b41eb45 100644 --- a/internal/pdns/pdns_test.go +++ b/internal/pdns/pdns_test.go @@ -532,6 +532,109 @@ func TestNewFromEnv(t *testing.T) { } } +func TestTSIGKey_CRUD(t *testing.T) { + t.Parallel() + + type state struct { + keys []TSIGKey + } + st := &state{ + keys: []TSIGKey{ + {Name: "existing", ID: "k1", Algorithm: "hmac-md5", Type: "TSIGKey"}, + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/servers/localhost/tsigkeys", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(st.keys) + case http.MethodPost: + body, _ := io.ReadAll(r.Body) + var req map[string]string + _ = json.Unmarshal(body, &req) + created := TSIGKey{ + Name: req["name"], + Algorithm: req["algorithm"], + ID: "newid", + Type: "TSIGKey", + } + if k := req["key"]; k != "" { + created.Key = k + } + // Append to state so a subsequent list would show it. + st.keys = append(st.keys, created) + _ = json.NewEncoder(w).Encode(created) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/v1/servers/localhost/tsigkeys/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/localhost/tsigkeys/") + // Remove from state + out := st.keys[:0] + for _, k := range st.keys { + if k.ID == id { + continue + } + out = append(out, k) + } + st.keys = out + w.WriteHeader(http.StatusNoContent) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + c := NewClient(srv.URL, "sekret") + ctx := context.Background() + + const hmacSHA256 = "hmac-sha256" + + // list + keys, err := c.ListTSIGKeys(ctx) + if err != nil || len(keys) != 1 || keys[0].Name != "existing" { + t.Fatalf("ListTSIGKeys got=%v err=%v", keys, err) + } + + // find + found, err := c.FindTSIGKeyByName(ctx, "existing") + if err != nil || found == nil || found.ID != "k1" { + t.Fatalf("FindTSIGKeyByName got=%#v err=%v", found, err) + } + + // create with key + created, err := c.CreateTSIGKey(ctx, "mykey", hmacSHA256, "b64secret") + if err != nil || created.ID == "" || created.Name != "mykey" || created.Algorithm != hmacSHA256 { + t.Fatalf("CreateTSIGKey got=%#v err=%v", created, err) + } + + // ensure returns existing when matches + ens, err := c.EnsureTSIGKey(ctx, "existing", "hmac-md5", "ignored") + if err != nil || ens.ID != "k1" { + t.Fatalf("EnsureTSIGKey existing got=%#v err=%v", ens, err) + } + + // ensure recreates when algorithm differs (existing removed, new created) + ens2, err := c.EnsureTSIGKey(ctx, "existing", hmacSHA256, "b64secret2") + if err != nil || ens2.ID != "newid" || ens2.Name != "existing" || ens2.Algorithm != hmacSHA256 { + t.Fatalf("EnsureTSIGKey recreate got=%#v err=%v", ens2, err) + } + + // delete by name + if err := c.DeleteTSIGKeyByName(ctx, "existing"); err != nil { + t.Fatalf("DeleteTSIGKeyByName err=%v", err) + } + after, _ := c.FindTSIGKeyByName(ctx, "existing") + if after != nil { + t.Fatalf("expected existing deleted, got %#v", after) + } +} + func TestSOASerialAutoChangesPerDay(t *testing.T) { t.Parallel() diff --git a/test/e2e/tsig/chainsaw-test.yaml b/test/e2e/tsig/chainsaw-test.yaml new file mode 100644 index 0000000..db55fdc --- /dev/null +++ b/test/e2e/tsig/chainsaw-test.yaml @@ -0,0 +1,493 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: dns-operator-tsig +spec: + clusters: + upstream: + kubeconfig: ../kubeconfig-upstream + downstream: + kubeconfig: ../kubeconfig-downstream + cluster: upstream + steps: + - name: Prereq - create DNSZoneClass in upstream + try: + - create: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + metadata: + name: powerdns-static-tsig + spec: + controllerName: powerdns + nameServerPolicy: + mode: Static + static: + servers: + - ns1.example.com + - ns2.example.com + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + metadata: + name: powerdns-static-tsig + + - name: Prereq - create DNSZoneClass in downstream + try: + - create: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + metadata: + name: powerdns-static-tsig + spec: + controllerName: powerdns + nameServerPolicy: + mode: Static + static: + servers: + - ns1.example.com + - ns2.example.com + - assert: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + metadata: + name: powerdns-static-tsig + + - name: Create DNSZone upstream + try: + - create: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + metadata: + name: example-com-tsig + spec: + domainName: example-tsig.com + dnsZoneClassName: powerdns-static-tsig + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + metadata: + name: example-com-tsig + + - name: Wait for DNSZone Accepted/Programmed upstream + try: + - sleep: + duration: 5s + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + metadata: + name: example-com-tsig + status: + conditions: + - type: Accepted + status: "True" + - type: Programmed + status: "True" + + - name: Confirm DNSZone exists downstream in mapped namespace + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - assert: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + metadata: + name: example-com-tsig + namespace: ($downstreamNamespaceName) + + - name: Create generated DNSZoneTSIGKey upstream (no secretRef) + try: + - create: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + spec: + dnsZoneRef: + name: example-com-tsig + keyName: datum-example-com-tsig-gen + algorithm: hmac-sha256 + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + ownerReferences: + - apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + name: example-com-tsig + + - name: Wait for generated DNSZoneTSIGKey Accepted/Programmed upstream + try: + - sleep: + duration: 5s + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + status: + tsigKeyName: datum-example-com-tsig-gen.example-tsig.com. + conditions: + - type: Accepted + status: "True" + - type: Programmed + status: "True" + + - name: Assert generated Secret exists upstream + try: + - assert: + cluster: upstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: example-com-tsig-gen + + - name: Confirm generated DNSZoneTSIGKey + Secret exist downstream + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - assert: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + - assert: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + + - name: Delete downstream generated Secret and ensure it is recreated + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - delete: + cluster: downstream + ref: + apiVersion: v1 + kind: Secret + namespace: ($downstreamNamespaceName) + name: example-com-tsig-gen + - sleep: + duration: 5s + - assert: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + + - name: Delete downstream generated DNSZoneTSIGKey and ensure it + Secret are recreated + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - script: + cluster: downstream + content: | + set -euo pipefail + # Find the mapped downstream namespace by label selector, then delete. + ns=$(kubectl get dnszonetsigkeys -A \ + -l meta.datumapis.com/upstream-namespace="$NAMESPACE",meta.datumapis.com/upstream-name=example-com-tsig-gen \ + -o jsonpath='{.items[0].metadata.namespace}') + kubectl -n "$ns" delete dnszonetsigkey example-com-tsig-gen --wait=false --ignore-not-found=true + - sleep: + duration: 8s + - assert: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + - assert: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + + - name: Delete upstream generated DNSZoneTSIGKey and ensure downstream DNSZoneTSIGKey + Secret are GC'd + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + name: example-com-tsig-gen + - sleep: + duration: 10s + - error: + cluster: downstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + - error: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: example-com-tsig-gen + namespace: ($downstreamNamespaceName) + + - name: Create BYO Secret + DNSZoneTSIGKey upstream + try: + - create: + cluster: upstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: byo-tsig + type: Opaque + stringData: + secret: supersecret + - create: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-byo + spec: + dnsZoneRef: + name: example-com-tsig + keyName: datum-example-com-tsig-byo + algorithm: hmac-sha256 + secretRef: + name: byo-tsig + + - name: Wait for BYO DNSZoneTSIGKey Accepted/Programmed upstream + try: + - sleep: + duration: 5s + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-byo + status: + tsigKeyName: datum-example-com-tsig-byo.example-tsig.com. + conditions: + - type: Accepted + status: "True" + - type: Programmed + status: "True" + + - name: Assert BYO Secret exists upstream + try: + - assert: + cluster: upstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: byo-tsig + + - name: Confirm BYO Secret exists downstream and recreates when deleted + try: + - script: + cluster: upstream + skipCommandOutput: true + skipLogOutput: true + content: | + kubectl get ns $NAMESPACE -o json + outputs: + - name: downstreamNamespaceName + value: (join('-', ['ns', json_parse($stdout).metadata.uid])) + - assert: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: byo-tsig + namespace: ($downstreamNamespaceName) + - delete: + cluster: downstream + ref: + apiVersion: v1 + kind: Secret + namespace: ($downstreamNamespaceName) + name: byo-tsig + - sleep: + duration: 5s + - assert: + cluster: downstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: byo-tsig + namespace: ($downstreamNamespaceName) + + - name: Edge case - bad secret surfaced via Accepted=False (missing secret key) + try: + - create: + cluster: upstream + resource: + apiVersion: v1 + kind: Secret + metadata: + name: bad-tsig + type: Opaque + stringData: + notsecret: foo + - create: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-bad + spec: + dnsZoneRef: + name: example-com-tsig + keyName: datum-example-com-tsig-bad + algorithm: hmac-sha256 + secretRef: + name: bad-tsig + - sleep: + duration: 5s + - assert: + cluster: upstream + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + metadata: + name: example-com-tsig-bad + status: + conditions: + - type: Accepted + status: "False" + reason: InvalidSecret + + - name: Teardown — delete keys, zone, classes + try: + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + name: example-com-tsig-gen + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + name: example-com-tsig-byo + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneTSIGKey + name: example-com-tsig-bad + - delete: + cluster: upstream + ref: + apiVersion: v1 + kind: Secret + name: byo-tsig + - delete: + cluster: upstream + ref: + apiVersion: v1 + kind: Secret + name: bad-tsig + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + name: example-com-tsig + - delete: + cluster: downstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + name: powerdns-static-tsig + - delete: + cluster: upstream + ref: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZoneClass + name: powerdns-static-tsig + diff --git a/test/e2e/chainsaw-test.yaml b/test/e2e/zones-and-records/chainsaw-test.yaml similarity index 98% rename from test/e2e/chainsaw-test.yaml rename to test/e2e/zones-and-records/chainsaw-test.yaml index 7d98d25..c816986 100644 --- a/test/e2e/chainsaw-test.yaml +++ b/test/e2e/zones-and-records/chainsaw-test.yaml @@ -2,13 +2,13 @@ apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test metadata: - name: dns-operator-end-to-end + name: dns-operator-zones-and-records spec: clusters: upstream: - kubeconfig: kubeconfig-upstream + kubeconfig: ../kubeconfig-upstream downstream: - kubeconfig: kubeconfig-downstream + kubeconfig: ../kubeconfig-downstream cluster: upstream steps: - name: Prereq - create DNSZoneClass in upstream @@ -404,3 +404,4 @@ spec: kind: DNSZoneClass check: ($error == null): true +