From dbc05784f3a5b065edb183b6ac5fc28ef2cb482d Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 8 Oct 2026 15:02:28 -0500 Subject: [PATCH] feat: Keep private DNS zones out of public DNS --- api/v1alpha1/dnszone_types.go | 16 ++ .../dns.networking.miloapis.com_dnszones.yaml | 12 + docs/architecture/api-reference.md | 8 + .../dnsrecordset_downstream_controller.go | 3 + .../dnsrecordset_powerdns_controller.go | 3 + .../dnsrecordset_replicator_controller.go | 25 +- .../dnszone_downstream_controller.go | 3 + .../dnszone_replicator_controller.go | 5 + .../controller/dnszonediscovery_controller.go | 5 + .../controller/private_dns_isolation_test.go | 221 ++++++++++++++++++ .../controller/private_dns_visibility_test.go | 88 +++++++ 11 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 internal/controller/private_dns_isolation_test.go create mode 100644 internal/controller/private_dns_visibility_test.go diff --git a/api/v1alpha1/dnszone_types.go b/api/v1alpha1/dnszone_types.go index f40447be..7d8d65bb 100644 --- a/api/v1alpha1/dnszone_types.go +++ b/api/v1alpha1/dnszone_types.go @@ -22,8 +22,24 @@ type DNSZoneSpec struct { // DNSZoneClassName references the DNSZoneClass used to provision this zone. // +kubebuilder:validation:Required DNSZoneClassName string `json:"dnsZoneClassName"` + + // Visibility selects public publication or private resolver context serving. + // Existing zones default to Public. Changing visibility requires a new zone. + // +kubebuilder:default=Public + // +kubebuilder:validation:Enum=Public;Private + // +kubebuilder:validation:XValidation:rule="oldSelf == self",message="visibility is immutable" + // +optional + Visibility DNSZoneVisibility `json:"visibility,omitempty"` } +// DNSZoneVisibility identifies the serving boundary of a zone. +type DNSZoneVisibility string + +const ( + DNSZoneVisibilityPublic DNSZoneVisibility = "Public" + DNSZoneVisibilityPrivate DNSZoneVisibility = "Private" +) + type DomainRefStatus struct { Nameservers []networkingv1alpha.Nameserver `json:"nameservers,omitempty"` } diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml index eef66c7e..b109c935 100644 --- a/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml +++ b/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml @@ -66,6 +66,18 @@ spec: rule: oldSelf == '' || self == oldSelf - message: Must have at least two segments separated by dots rule: self.indexOf('.') != -1 + visibility: + default: Public + description: |- + Visibility selects public publication or private resolver context serving. + Existing zones default to Public. Changing visibility requires a new zone. + enum: + - Public + - Private + type: string + x-kubernetes-validations: + - message: visibility is immutable + rule: oldSelf == self required: - dnsZoneClassName - domainName diff --git a/docs/architecture/api-reference.md b/docs/architecture/api-reference.md index d09bd799..daa4cbfb 100644 --- a/docs/architecture/api-reference.md +++ b/docs/architecture/api-reference.md @@ -44,6 +44,12 @@ spec: Namespaced. Models a single domain. +`spec.visibility` accepts `Public` or `Private` and defaults to `Public`. +Visibility is immutable. Create a new zone to change its serving boundary. +Private zones and their records are excluded from public replication, serving, +and discovery. This boundary prepares private zone support; it does not provision +a private resolver or connect a zone to a VPC. + | Field | Type | Description | |-------|------|-------------| | `spec.domainName` | string | Required FQDN (e.g. `example.com`). Immutable once set. | @@ -62,6 +68,8 @@ metadata: spec: domainName: example.com dnsZoneClassName: powerdns + # Optional. Omitted visibility preserves public DNS behavior. + visibility: Public ``` ## DNSRecordSet diff --git a/internal/controller/dnsrecordset_downstream_controller.go b/internal/controller/dnsrecordset_downstream_controller.go index 7adf327b..f20f1ac5 100644 --- a/internal/controller/dnsrecordset_downstream_controller.go +++ b/internal/controller/dnsrecordset_downstream_controller.go @@ -97,6 +97,9 @@ func (r *DNSRecordSetReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, err } + if zone.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return ctrl.Result{}, nil + } if zone.Spec.DNSZoneClassName == "" || zone.Spec.DNSZoneClassName != r.DNSHandler.Client.Name { logger.V(1).Info("Resource belongs to a different class. Not Reconciling") return ctrl.Result{}, nil diff --git a/internal/controller/dnsrecordset_powerdns_controller.go b/internal/controller/dnsrecordset_powerdns_controller.go index ef07484c..be2f5675 100644 --- a/internal/controller/dnsrecordset_powerdns_controller.go +++ b/internal/controller/dnsrecordset_powerdns_controller.go @@ -88,6 +88,9 @@ func (r *DNSRecordSetPowerDNSReconciler) Reconcile( return reconcile.Result{}, err } + if zone.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return reconcile.Result{}, nil + } if !zone.DeletionTimestamp.IsZero() { logger.V(1).Info("zone deleting; skipping") return reconcile.Result{}, nil diff --git a/internal/controller/dnsrecordset_replicator_controller.go b/internal/controller/dnsrecordset_replicator_controller.go index 8956ab79..1ea42e57 100644 --- a/internal/controller/dnsrecordset_replicator_controller.go +++ b/internal/controller/dnsrecordset_replicator_controller.go @@ -56,6 +56,17 @@ func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile. return ctrl.Result{}, client.IgnoreNotFound(err) } + // Resolve visibility before installing public finalizers or creating events. + var zone dnsv1alpha1.DNSZone + zoneErr := upstreamCluster.GetClient().Get(ctx, + types.NamespacedName{Namespace: req.Namespace, Name: upstream.Spec.DNSZoneRef.Name}, &zone) + if zoneErr != nil && !apierrors.IsNotFound(zoneErr) { + return ctrl.Result{}, zoneErr + } + if zoneErr == nil && zone.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return ctrl.Result{}, nil + } + // Build a typed clientset from the upstream cluster REST config so we can // create events.k8s.io/v1 Event objects directly. Event emission is // best-effort: if clientset construction fails we log and continue. @@ -71,8 +82,9 @@ func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile. strategy := downstreamclient.NewMappedNamespaceResourceStrategy(req.ClusterName, upstreamCluster.GetClient(), r.DownstreamClient) - // Ensure upstream finalizer (non-deletion path; replaces webhook defaulter) - if upstream.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(&upstream, rsFinalizer) { + // Claim the public deletion lifecycle only once zone visibility is known. + // A record can arrive before its private zone in a project control plane. + if zoneErr == nil && upstream.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(&upstream, rsFinalizer) { base := upstream.DeepCopy() controllerutil.AddFinalizer(&upstream, rsFinalizer) if err := upstreamCluster.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil { @@ -101,10 +113,10 @@ func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile. // Gate on referenced DNSZone early and update status when missing. // We fetch the zone before emitting the spec-update event so we can include // the domain name annotation. - 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) { + if zoneErr != nil { + if apierrors.IsNotFound(zoneErr) { zoneMsg := fmt.Sprintf("DNSZone %q not found", upstream.Spec.DNSZoneRef.Name) + base := upstream.DeepCopy() if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{ Type: CondAccepted, Status: metav1.ConditionFalse, @@ -113,7 +125,6 @@ func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile. ObservedGeneration: upstream.Generation, LastTransitionTime: metav1.NewTime(time.Now()), }) { - base := upstream.DeepCopy() if err := upstreamCluster.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil { return ctrl.Result{}, err } @@ -121,7 +132,7 @@ func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile. lg.Info("referenced DNSZone not found; marked Accepted=False and exiting early", "dnsZone", upstream.Spec.DNSZoneRef.Name) return ctrl.Result{}, nil } - return ctrl.Result{}, err + return ctrl.Result{}, zoneErr } // If the zone is being deleted, do not program downstream recordset diff --git a/internal/controller/dnszone_downstream_controller.go b/internal/controller/dnszone_downstream_controller.go index 01380bae..b8f640fb 100644 --- a/internal/controller/dnszone_downstream_controller.go +++ b/internal/controller/dnszone_downstream_controller.go @@ -44,6 +44,9 @@ func (r *DNSZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err := r.Get(ctx, req.NamespacedName, &zone); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } + if zone.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return ctrl.Result{}, nil + } if zone.Spec.DNSZoneClassName == "" || zone.Spec.DNSZoneClassName != r.DNSHandler.Client.Name { logger.V(1).Info("Resource belongs to different class. Not Reconciling") diff --git a/internal/controller/dnszone_replicator_controller.go b/internal/controller/dnszone_replicator_controller.go index be370726..c3e5c792 100644 --- a/internal/controller/dnszone_replicator_controller.go +++ b/internal/controller/dnszone_replicator_controller.go @@ -71,6 +71,11 @@ func (r *DNSZoneReplicator) Reconcile(ctx context.Context, req mcreconcile.Reque return ctrl.Result{}, client.IgnoreNotFound(err) } + // Private zones must not claim a public apex or enter public replication. + if upstream.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return ctrl.Result{}, nil + } + // Build a typed clientset from the upstream cluster REST config so we can // create events.k8s.io/v1 Event objects directly. Event emission is // best-effort: if clientset construction fails we log and continue. diff --git a/internal/controller/dnszonediscovery_controller.go b/internal/controller/dnszonediscovery_controller.go index 7d4cbe3e..484c4ed3 100644 --- a/internal/controller/dnszonediscovery_controller.go +++ b/internal/controller/dnszonediscovery_controller.go @@ -75,6 +75,11 @@ func (r *DNSZoneDiscoveryReplicator) Reconcile(ctx context.Context, req mcreconc return ctrl.Result{}, client.IgnoreNotFound(err) } + // Private zone names must not be sent to public discovery resolvers. + if zone.Spec.Visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + return ctrl.Result{}, nil + } + // Ensure OwnerReference to referenced DNSZone early if !metav1.IsControlledBy(&dzd, &zone) { base := dzd.DeepCopy() diff --git a/internal/controller/private_dns_isolation_test.go b/internal/controller/private_dns_isolation_test.go new file mode 100644 index 00000000..05be04b5 --- /dev/null +++ b/internal/controller/private_dns_isolation_test.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "reflect" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + 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/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/cluster" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" +) + +type isolationCluster struct { + cluster.Cluster + client client.Client +} + +func (c *isolationCluster) GetClient() client.Client { return c.client } +func (c *isolationCluster) GetConfig() *rest.Config { + return &rest.Config{Host: "http://127.0.0.1:1"} +} + +type isolationManager struct { + mcmanager.Manager + upstream cluster.Cluster +} + +func (m *isolationManager) GetCluster(context.Context, multicluster.ClusterName) (cluster.Cluster, error) { + return m.upstream, nil +} + +func TestPublicControllersSkipPrivateZones(t *testing.T) { + zoneKey := client.ObjectKey{Namespace: "project-a", Name: "private-zone"} + recordKey := client.ObjectKey{Namespace: zoneKey.Namespace, Name: "api"} + discoveryKey := client.ObjectKey{Namespace: zoneKey.Namespace, Name: "discover"} + tests := []struct { + name string + reconcile func(context.Context, client.Client, mcmanager.Manager) (ctrl.Result, error) + }{ + {"zone serving", func(ctx context.Context, cl client.Client, _ mcmanager.Manager) (ctrl.Result, error) { + return (&DNSZoneReconciler{Client: cl}).Reconcile(ctx, ctrl.Request{NamespacedName: zoneKey}) + }}, + {"record serving", func(ctx context.Context, cl client.Client, _ mcmanager.Manager) (ctrl.Result, error) { + return (&DNSRecordSetReconciler{Client: cl}).Reconcile(ctx, ctrl.Request{NamespacedName: recordKey}) + }}, + {"PowerDNS serving", func(ctx context.Context, cl client.Client, _ mcmanager.Manager) (ctrl.Result, error) { + return (&DNSRecordSetPowerDNSReconciler{Client: cl}).Reconcile(ctx, PowerDNSRecordSetReconcileRequest{ + Request: ctrl.Request{NamespacedName: zoneKey}, RecordSetType: "AAAA", RecordSetName: "api", + }) + }}, + {"zone replication", func(ctx context.Context, _ client.Client, mgr mcmanager.Manager) (ctrl.Result, error) { + return (&DNSZoneReplicator{mgr: mgr}).Reconcile(ctx, mcreconcile.Request{ + ClusterName: "project-a", Request: ctrl.Request{NamespacedName: zoneKey}, + }) + }}, + {"record replication", func(ctx context.Context, _ client.Client, mgr mcmanager.Manager) (ctrl.Result, error) { + return (&DNSRecordSetReplicator{mgr: mgr}).Reconcile(ctx, mcreconcile.Request{ + ClusterName: "project-a", Request: ctrl.Request{NamespacedName: recordKey}, + }) + }}, + {"public discovery", func(ctx context.Context, _ client.Client, mgr mcmanager.Manager) (ctrl.Result, error) { + return (&DNSZoneDiscoveryReplicator{mgr: mgr}).Reconcile(ctx, mcreconcile.Request{ + ClusterName: "project-a", Request: ctrl.Request{NamespacedName: discoveryKey}, + }) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + zone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: zoneKey.Name, Namespace: zoneKey.Namespace, UID: "zone-uid"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "prod.example.com", DNSZoneClassName: "public-class", + Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}, + } + record := &dnsv1alpha1.DNSRecordSet{ + ObjectMeta: metav1.ObjectMeta{Name: recordKey.Name, Namespace: recordKey.Namespace}, + Spec: dnsv1alpha1.DNSRecordSetSpec{ + DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}, RecordType: dnsv1alpha1.RRTypeAAAA, + }, + } + discovery := &dnsv1alpha1.DNSZoneDiscovery{ + ObjectMeta: metav1.ObjectMeta{Name: discoveryKey.Name, Namespace: discoveryKey.Namespace}, + Spec: dnsv1alpha1.DNSZoneDiscoverySpec{DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}}, + } + objects := []client.Object{zone, record, discovery} + cl := fake.NewClientBuilder().WithScheme(newTestScheme(t)).WithObjects(objects...). + WithStatusSubresource(objects...).Build() + mgr := &isolationManager{upstream: &isolationCluster{client: cl}} + ctx := context.Background() + before := make([]client.Object, len(objects)) + for i, obj := range objects { + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + t.Fatal(err) + } + before[i] = obj.DeepCopyObject().(client.Object) + } + // Omit public backends, downstream clients, and discovery configuration. + // Private resources must be rejected before any of them are used. + for attempt := 0; attempt < 2; attempt++ { + result, err := test.reconcile(ctx, cl, mgr) + if err != nil || result != (ctrl.Result{}) { + t.Fatalf("private resource was not ignored: result=%+v err=%v", result, err) + } + } + for i, obj := range objects { + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(obj, before[i]) { + t.Fatalf("public controller modified %T: before=%+v after=%+v", obj, before[i], obj) + } + } + }) + } +} + +func TestPublicRecordReplicationRetainsFinalizer(t *testing.T) { + for _, visibility := range []dnsv1alpha1.DNSZoneVisibility{"", dnsv1alpha1.DNSZoneVisibilityPublic} { + t.Run("visibility="+string(visibility), func(t *testing.T) { + t.Parallel() + zone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: "public-zone", Namespace: "project-a"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "example.com", DNSZoneClassName: "public-class", + Visibility: visibility}, + } + record := &dnsv1alpha1.DNSRecordSet{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: zone.Namespace}, + Spec: dnsv1alpha1.DNSRecordSetSpec{DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}}, + } + cl := fake.NewClientBuilder().WithScheme(newTestScheme(t)).WithObjects(zone, record).Build() + mgr := &isolationManager{upstream: &isolationCluster{client: cl}} + req := mcreconcile.Request{ClusterName: "project-a", + Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(record)}} + if _, err := (&DNSRecordSetReplicator{mgr: mgr}).Reconcile(context.Background(), req); err != nil { + t.Fatal(err) + } + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(record), record); err != nil { + t.Fatal(err) + } + if len(record.Finalizers) != 1 || record.Finalizers[0] != rsFinalizer { + t.Fatalf("public record lost its deletion finalizer: %v", record.Finalizers) + } + }) + } +} + +func TestRecordCreatedBeforePrivateZoneAvoidsPublicFinalizer(t *testing.T) { + zone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: "private-zone", Namespace: "project-a"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "prod.example.com", DNSZoneClassName: "public-class", + Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}, + } + record := &dnsv1alpha1.DNSRecordSet{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: zone.Namespace}, + Spec: dnsv1alpha1.DNSRecordSetSpec{DNSZoneRef: corev1.LocalObjectReference{Name: zone.Name}}, + } + cl := fake.NewClientBuilder().WithScheme(newTestScheme(t)).WithObjects(record). + WithStatusSubresource(record).Build() + mgr := &isolationManager{upstream: &isolationCluster{client: cl}} + reconciler := &DNSRecordSetReplicator{mgr: mgr} + req := mcreconcile.Request{ClusterName: "project-a", + Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(record)}} + ctx := context.Background() + if _, err := reconciler.Reconcile(ctx, req); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(record), record); err != nil { + t.Fatal(err) + } + if len(record.Finalizers) != 0 || !apimeta.IsStatusConditionFalse(record.Status.Conditions, CondAccepted) { + t.Fatalf("unresolved zone must wait without a public finalizer: %+v", record) + } + before := record.DeepCopy() + if err := cl.Create(ctx, zone); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, req); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(record), record); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(record, before) { + t.Fatalf("private zone reconciliation modified a waiting record: %+v", record) + } +} + +func TestPublicRecordDeletionCompletesAfterZoneDisappears(t *testing.T) { + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "project-a", UID: "project-uid"}} + record := &dnsv1alpha1.DNSRecordSet{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: namespace.Name, UID: "record-uid", + Finalizers: []string{rsFinalizer}, DeletionTimestamp: &metav1.Time{Time: time.Now()}}, + Spec: dnsv1alpha1.DNSRecordSetSpec{DNSZoneRef: corev1.LocalObjectReference{Name: "deleted-zone"}}, + } + scheme := newTestScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(namespace, record).Build() + downstream := fake.NewClientBuilder().WithScheme(scheme).Build() + mgr := &isolationManager{upstream: &isolationCluster{client: cl}} + req := mcreconcile.Request{ClusterName: "project-a", + Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(record)}} + ctx := context.Background() + if _, err := (&DNSRecordSetReplicator{mgr: mgr, DownstreamClient: downstream}).Reconcile(ctx, req); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(record), record); !apierrors.IsNotFound(err) { + t.Fatalf("public record deletion did not finish after its zone disappeared: %v", err) + } +} diff --git a/internal/controller/private_dns_visibility_test.go b/internal/controller/private_dns_visibility_test.go new file mode 100644 index 00000000..d5bbbe15 --- /dev/null +++ b/internal/controller/private_dns_visibility_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "path/filepath" + "strings" + "testing" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func TestDNSZoneVisibilityAdmission(t *testing.T) { + env := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + BinaryAssetsDirectory: getFirstFoundEnvTestBinaryDir(), + } + cfg, err := env.Start() + if err != nil { + t.Fatalf("start envtest: %v", err) + } + t.Cleanup(func() { _ = env.Stop() }) + cl, err := client.New(cfg, client.Options{Scheme: newTestScheme(t)}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + newZone := func(name string, visibility dnsv1alpha1.DNSZoneVisibility) *dnsv1alpha1.DNSZone { + return &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "prod.example.com", DNSZoneClassName: "powerdns", + Visibility: visibility}, + } + } + + t.Run("omitted visibility defaults to public", func(t *testing.T) { + zone := newZone("legacy", "") + if err := cl.Create(ctx, zone); err != nil { + t.Fatal(err) + } + if zone.Spec.Visibility != dnsv1alpha1.DNSZoneVisibilityPublic { + t.Fatalf("default visibility: %q", zone.Spec.Visibility) + } + zone.Spec.Visibility = "" + if err := cl.Update(ctx, zone); err != nil { + t.Fatalf("omitted public visibility must remain compatible: %v", err) + } + }) + t.Run("invalid visibility is rejected", func(t *testing.T) { + if err := cl.Create(ctx, newZone("invalid", "Internal")); !apierrors.IsInvalid(err) { + t.Fatalf("expected validation error, got %v", err) + } + }) + for _, visibility := range []dnsv1alpha1.DNSZoneVisibility{ + dnsv1alpha1.DNSZoneVisibilityPublic, dnsv1alpha1.DNSZoneVisibilityPrivate, + } { + t.Run("immutable "+string(visibility), func(t *testing.T) { + zone := newZone("immutable-"+strings.ToLower(string(visibility)), visibility) + if err := cl.Create(ctx, zone); err != nil { + t.Fatal(err) + } + zone.Spec.DNSZoneClassName = "another-class" + if err := cl.Update(ctx, zone); err != nil { + t.Fatalf("unchanged visibility must allow other updates: %v", err) + } + if visibility == dnsv1alpha1.DNSZoneVisibilityPublic { + zone.Spec.Visibility = dnsv1alpha1.DNSZoneVisibilityPrivate + } else { + zone.Spec.Visibility = dnsv1alpha1.DNSZoneVisibilityPublic + } + if err := cl.Update(ctx, zone); !apierrors.IsInvalid(err) { + t.Fatalf("expected immutable visibility error, got %v", err) + } + if visibility == dnsv1alpha1.DNSZoneVisibilityPrivate { + zone.Spec.Visibility = "" + if err := cl.Update(ctx, zone); !apierrors.IsInvalid(err) { + t.Fatalf("removing private visibility must not publish the zone: %v", err) + } + } + }) + } +}