diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fe465390..2c0141e0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main jobs: e2e: diff --git a/.gitignore b/.gitignore index b48f9e6e..50697f11 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ go.work.sum .claude/settings.local.json .claude/worktrees/ .mcp.json + +# Local kind output from the internal DNS end-to-end harness (includes kubeconfigs) +.internal-dns-e2e/ diff --git a/api/v1alpha1/internaldns_access_test.go b/api/v1alpha1/internaldns_access_test.go new file mode 100644 index 00000000..887b0234 --- /dev/null +++ b/api/v1alpha1/internaldns_access_test.go @@ -0,0 +1,17 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" +) + +func TestResolverAccessHasNoExpiryOrClusterIdentity(t *testing.T) { + a := DNSResolverAccessVersion{Epoch: 1, Sequence: 2} + b, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"epoch":1,"sequence":2}` { + t.Fatalf("unexpected access fence: %s", b) + } +} diff --git a/api/v1alpha1/internaldns_context_boundary_envtest_test.go b/api/v1alpha1/internaldns_context_boundary_envtest_test.go new file mode 100644 index 00000000..2c099c0c --- /dev/null +++ b/api/v1alpha1/internaldns_context_boundary_envtest_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha1 + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func validateResolverContextBoundaries(t *testing.T, cl client.Client) { + t.Helper() + for _, kind := range []string{"DNSZoneAssociation", "DNSNamingPolicy"} { + t.Run(kind+"-context-boundary", func(t *testing.T) { + newObject := func(name string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": GroupVersion.String(), "kind": kind, + "metadata": map[string]any{"name": name, "namespace": "validation"}, + "spec": map[string]any{ + "dnsZoneRef": map[string]any{"name": "zone", "uid": "zone-uid"}, + "additionalNames": []any{}, + }, + }} + } + ctx := context.Background() + for _, ref := range []map[string]any{nil, {"name": ""}, {"name": "", "uid": "context-uid"}} { + o := newObject("context-required") + if ref != nil { + if err := unstructured.SetNestedMap(o.Object, ref, "spec", "resolverContextRef"); err != nil { + t.Fatal(err) + } + } + if err := unstructured.SetNestedMap(o.Object, map[string]any{"name": "network", "uid": "network-uid"}, "spec", "vpcRef"); err != nil { + t.Fatal(err) + } + if err := cl.Create(ctx, o); !apierrors.IsInvalid(err) { + t.Fatalf("expected rejection without a named DNS context, got %v", err) + } + } + o := newObject("context-selected") + if err := unstructured.SetNestedMap(o.Object, map[string]any{"name": "context", "uid": "context-uid"}, "spec", "resolverContextRef"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedMap(o.Object, map[string]any{"name": "network", "uid": "network-uid"}, "spec", "vpcRef"); err != nil { + t.Fatal(err) + } + if err := cl.Create(ctx, o); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(o), o); err != nil { + t.Fatal(err) + } + if _, found, err := unstructured.NestedMap(o.Object, "spec", "vpcRef"); err != nil || found { + t.Fatalf("network reference was retained in the DNS API: found=%t err=%v", found, err) + } + if err := unstructured.SetNestedMap(o.Object, map[string]any{"name": "", "uid": "context-uid"}, "spec", "resolverContextRef"); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, o); !apierrors.IsInvalid(err) { + t.Fatalf("expected rejection when clearing the DNS context, got %v", err) + } + }) + } +} diff --git a/api/v1alpha1/internaldns_project_types.go b/api/v1alpha1/internaldns_project_types.go new file mode 100644 index 00000000..5ffcb4a0 --- /dev/null +++ b/api/v1alpha1/internaldns_project_types.go @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// DNSObjectReference pins a namespaced object by API-assigned UID. Generation +// is used where a writer must also be fenced against policy changes. +type DNSObjectReference struct { + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:MaxLength=128 + UID types.UID `json:"uid,omitempty"` + // +kubebuilder:validation:Minimum=0 + Generation int64 `json:"generation,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="!has(self.resolverContextRef) || ((self.resolverContextRef.name.size() == 0 && !has(self.resolverContextRef.uid) && (!has(self.resolverContextRef.generation) || self.resolverContextRef.generation == 0)) || (self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() > 0))",message="a nonempty resolverContextRef must pin name and UID" +// +kubebuilder:validation:XValidation:rule="has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0",message="dnsZoneRef must pin the zone UID" +// +kubebuilder:validation:XValidation:rule="self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() > 0",message="resolverContextRef is required" +// +kubebuilder:validation:XValidation:rule="self.dnsZoneRef.name == oldSelf.dnsZoneRef.name && self.dnsZoneRef.uid == oldSelf.dnsZoneRef.uid",message="dnsZoneRef identity is immutable" +// +kubebuilder:validation:XValidation:rule="self.resolverContextRef.name == oldSelf.resolverContextRef.name && self.resolverContextRef.uid == oldSelf.resolverContextRef.uid",message="resolverContextRef identity is immutable" +type DNSZoneAssociationSpec struct { + DNSZoneRef DNSObjectReference `json:"dnsZoneRef"` + ResolverContextRef DNSObjectReference `json:"resolverContextRef"` +} + +type DNSZoneAssociationStatus struct { + ResolvedDNSZoneRef DNSObjectReference `json:"resolvedDNSZoneRef,omitempty"` + ResolvedResolverContextRef DNSObjectReference `json:"resolvedResolverContextRef,omitempty"` + BindingRef *DNSObjectReference `json:"bindingRef,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// DNSResolverContext is the DNS-owned serving and namespace boundary created by +// a trusted network integration. consumerID is opaque to DNS and immutable. +// +kubebuilder:validation:XValidation:rule="oldSelf.consumerID == self.consumerID",message="consumerID is immutable" +type DNSResolverContextSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + ConsumerID string `json:"consumerID"` + ManagedNamespace DNSResolverContextManagedNamespace `json:"managedNamespace,omitempty"` +} + +type DNSResolverContextManagedNamespace struct { + Enabled bool `json:"enabled,omitempty"` +} + +type DNSResolverContextManagedNamespaceStatus struct { + DNSZoneRef DNSObjectReference `json:"dnsZoneRef,omitempty"` + Suffix string `json:"suffix,omitempty"` +} + +type DNSResolverContextStatus struct { + ManagedNamespace DNSResolverContextManagedNamespaceStatus `json:"managedNamespace,omitempty"` + ServingTargets []DNSResolverContextServingTarget `json:"servingTargets,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type DNSResolverContextServingTarget struct { + Region string `json:"region"` + Shard string `json:"shard"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSResolverContext struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSResolverContextSpec `json:"spec"` + Status DNSResolverContextStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSResolverContextList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSResolverContext `json:"items"` +} + +type DNSResolverAccessVersion struct { + // +kubebuilder:validation:Minimum=1 + Epoch int64 `json:"epoch"` + // +kubebuilder:validation:Minimum=1 + Sequence int64 `json:"sequence"` +} + +// +kubebuilder:validation:XValidation:rule="self.contextRef.name.size() > 0 && has(self.contextRef.uid) && self.contextRef.uid.size() > 0",message="contextRef requires name and UID" +// +kubebuilder:validation:XValidation:rule="self.contextRef == oldSelf.contextRef",message="contextRef is immutable" +// +kubebuilder:validation:XValidation:rule="self.region == oldSelf.region && self.destinationAddress == oldSelf.destinationAddress",message="access destination is immutable" +type DNSResolverAccessBindingSpec struct { + ContextRef DNSObjectReference `json:"contextRef"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Region string `json:"region"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="destinationAddress must be an IP address" + DestinationAddress string `json:"destinationAddress"` +} + +type DNSResolverAccessBindingStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + BindingRef *DNSObjectReference `json:"bindingRef,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSResolverAccessBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSResolverAccessBindingSpec `json:"spec"` + Status DNSResolverAccessBindingStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSResolverAccessBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSResolverAccessBinding `json:"items"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSZoneAssociation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSZoneAssociationSpec `json:"spec"` + Status DNSZoneAssociationStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSZoneAssociationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSZoneAssociation `json:"items"` +} + +const DNSRegistrationMaxTTLSeconds int32 = 30 + +// +kubebuilder:validation:XValidation:rule="has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0",message="dnsZoneRef must pin the zone UID" +// +kubebuilder:validation:XValidation:rule="self.dnsZoneRef.name == oldSelf.dnsZoneRef.name && self.dnsZoneRef.uid == oldSelf.dnsZoneRef.uid",message="dnsZoneRef identity is immutable" +// +kubebuilder:validation:XValidation:rule="self.name == oldSelf.name",message="name is immutable" +type DNSRegistrationSpec struct { + DNSZoneRef DNSObjectReference `json:"dnsZoneRef"` + // Name is the owner name, relative to the zone or fully qualified. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^(@|[A-Za-z0-9*._-]+)$` + Name string `json:"name"` + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=14 + // +listType=set + RecordTypes []RRType `json:"recordTypes"` + // TTLSeconds bounds cached dynamic answers to at most 30 seconds after withdrawal. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=30 + TTLSeconds int32 `json:"ttlSeconds"` + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=253 + // +listType=set + ReservedDescendants []string `json:"reservedDescendants,omitempty"` +} + +type DNSRegistrationStatus struct { + CanonicalFQDN string `json:"canonicalFQDN,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + PublicationWriterEpoch int64 `json:"publicationWriterEpoch,omitempty"` + PublicationRevision int64 `json:"publicationRevision,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSRegistration struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSRegistrationSpec `json:"spec"` + Status DNSRegistrationStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSRegistrationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSRegistration `json:"items"` +} + +type DNSContributionRecordSet struct { + RecordType RRType `json:"recordType"` + // +kubebuilder:validation:MaxItems=256 + Records []RecordEntry `json:"records"` +} + +// +kubebuilder:validation:XValidation:rule="self.registrationRef.name.size() > 0 && has(self.registrationRef.uid) && self.registrationRef.uid.size() > 0 && has(self.registrationRef.generation) && self.registrationRef.generation > 0",message="registrationRef requires name, UID, and generation" +// +kubebuilder:validation:XValidation:rule="self.registrationRef.name == oldSelf.registrationRef.name && self.registrationRef.uid == oldSelf.registrationRef.uid",message="contribution registration identity is immutable" +type DNSRecordContributionSpec struct { + RegistrationRef DNSObjectReference `json:"registrationRef"` + // +kubebuilder:validation:MaxItems=14 + // +listType=map + // +listMapKey=recordType + RecordSets []DNSContributionRecordSet `json:"recordSets"` + Eligible bool `json:"eligible"` + // +kubebuilder:validation:MaxLength=1024 + Reason string `json:"reason,omitempty"` +} + +type DNSRecordContributionStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + PublishedRevision int64 `json:"publishedRevision,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSRecordContribution struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSRecordContributionSpec `json:"spec"` + Status DNSRecordContributionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSRecordContributionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSRecordContribution `json:"items"` +} + +// +kubebuilder:validation:XValidation:rule="!has(self.resolverContextRef) || ((self.resolverContextRef.name.size() == 0 && !has(self.resolverContextRef.uid) && (!has(self.resolverContextRef.generation) || self.resolverContextRef.generation == 0)) || (self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() > 0))",message="a nonempty resolverContextRef must pin name and UID" +// +kubebuilder:validation:XValidation:rule="!has(self.dnsZoneRef) || self.dnsZoneRef.name.size() == 0 || (has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0)",message="a selected dnsZoneRef must pin the zone UID" +// +kubebuilder:validation:XValidation:rule="self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() > 0",message="resolverContextRef is required" +// +kubebuilder:validation:XValidation:rule="self.resolverContextRef.name == oldSelf.resolverContextRef.name && self.resolverContextRef.uid == oldSelf.resolverContextRef.uid",message="resolverContextRef identity is immutable" +// +kubebuilder:validation:XValidation:rule="(has(self.dnsZoneRef) ? self.dnsZoneRef.name : \"\") == (has(oldSelf.dnsZoneRef) ? oldSelf.dnsZoneRef.name : \"\") && ((has(self.dnsZoneRef) && has(self.dnsZoneRef.uid)) ? self.dnsZoneRef.uid : \"\") == ((has(oldSelf.dnsZoneRef) && has(oldSelf.dnsZoneRef.uid)) ? oldSelf.dnsZoneRef.uid : \"\")",message="dnsZoneRef identity is immutable" +type DNSNamingPolicySpec struct { + ResolverContextRef DNSObjectReference `json:"resolverContextRef"` + // +kubebuilder:validation:MaxItems=256 + AdditionalNames []DNSAdditionalNameRule `json:"additionalNames"` + DNSZoneRef DNSObjectReference `json:"dnsZoneRef,omitempty"` + // +kubebuilder:validation:MaxLength=253 + ScopePrefix string `json:"scopePrefix,omitempty"` + Priority int32 `json:"priority,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0",message="dnsZoneRef must pin the zone UID" +type DNSAdditionalNameRule struct { + Selector *DNSRegistrationSelector `json:"selector,omitempty"` + DNSZoneRef DNSObjectReference `json:"dnsZoneRef"` + // +kubebuilder:validation:MaxLength=253 + NamePrefix string `json:"namePrefix"` +} + +// DNSRegistrationSelector matches registration labels using Kubernetes selector semantics. +type DNSRegistrationSelector struct { + // +kubebuilder:validation:MaxProperties=64 + MatchLabels map[string]string `json:"matchLabels,omitempty"` + // +kubebuilder:validation:MaxItems=64 + MatchExpressions []DNSRegistrationSelectorRequirement `json:"matchExpressions,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="(self.operator in ['In', 'NotIn']) ? (has(self.values) && self.values.size() > 0) : (!has(self.values) || self.values.size() == 0)",message="selector operator requires compatible values" +type DNSRegistrationSelectorRequirement struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=317 + Key string `json:"key"` + // +kubebuilder:validation:Enum=In;NotIn;Exists;DoesNotExist + Operator metav1.LabelSelectorOperator `json:"operator"` + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MaxLength=63 + // +kubebuilder:validation:items:Pattern=`^([A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?)?$` + Values []string `json:"values,omitempty"` +} + +// AsLabelSelector converts the API selector for Kubernetes label matching. +func (s *DNSRegistrationSelector) AsLabelSelector() *metav1.LabelSelector { + if s == nil { + return nil + } + out := &metav1.LabelSelector{MatchLabels: s.MatchLabels} + for _, requirement := range s.MatchExpressions { + out.MatchExpressions = append(out.MatchExpressions, metav1.LabelSelectorRequirement{ + Key: requirement.Key, Operator: requirement.Operator, Values: requirement.Values, + }) + } + return out +} + +type DNSNamingPolicyStatus struct { + ResolvedResolverContextRef DNSObjectReference `json:"resolvedResolverContextRef,omitempty"` + ResolvedDNSZoneRef DNSObjectReference `json:"resolvedDNSZoneRef,omitempty"` + ResolvedAdditionalNames []DNSResolvedAdditionalNameRule `json:"resolvedAdditionalNames,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type DNSResolvedAdditionalNameRule struct { + Selector *metav1.LabelSelector `json:"selector,omitempty"` + DNSZoneRef DNSObjectReference `json:"dnsZoneRef"` + NamePrefix string `json:"namePrefix"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project" +type DNSNamingPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec DNSNamingPolicySpec `json:"spec"` + Status DNSNamingPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DNSNamingPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSNamingPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DNSZoneAssociation{}, &DNSZoneAssociationList{}, &DNSResolverContext{}, &DNSResolverContextList{}, &DNSResolverAccessBinding{}, &DNSResolverAccessBindingList{}, &DNSRegistration{}, &DNSRegistrationList{}, &DNSRecordContribution{}, &DNSRecordContributionList{}, &DNSNamingPolicy{}, &DNSNamingPolicyList{}) +} diff --git a/api/v1alpha1/internaldns_types_test.go b/api/v1alpha1/internaldns_types_test.go new file mode 100644 index 00000000..14fe6d11 --- /dev/null +++ b/api/v1alpha1/internaldns_types_test.go @@ -0,0 +1,30 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" +) + +func TestDNSPoliciesSelectResolverContexts(t *testing.T) { + for name, value := range map[string]any{ + "association context": DNSZoneAssociationSpec{ResolverContextRef: DNSObjectReference{Name: "ctx", UID: "ctx-uid"}}, + "naming context": DNSNamingPolicySpec{ResolverContextRef: DNSObjectReference{Name: "ctx", UID: "ctx-uid"}}, + } { + t.Run(name, func(t *testing.T) { + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var object map[string]any + if err := json.Unmarshal(data, &object); err != nil { + t.Fatal(err) + } + if _, hasVPC := object["vpcRef"]; hasVPC { + t.Fatal("DNS policies must not expose VPC references") + } + if _, hasContext := object["resolverContextRef"]; !hasContext { + t.Fatal("expected value-typed resolverContextRef property") + } + }) + } +} diff --git a/api/v1alpha1/internaldns_validation_envtest_test.go b/api/v1alpha1/internaldns_validation_envtest_test.go new file mode 100644 index 00000000..106a41f3 --- /dev/null +++ b/api/v1alpha1/internaldns_validation_envtest_test.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha1 + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// These tests exercise the served CRD schemas without an admission webhook. +func TestPrivateDNSValidationAtAPIServer(t *testing.T) { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + t.Skip("set KUBEBUILDER_ASSETS to test private DNS schemas against the API server") + } + environment := &envtest.Environment{BinaryAssetsDirectory: assets, CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true} + cfg, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + cl, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := cl.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "validation"}}); err != nil { + t.Fatal(err) + } + validateResolverContextBoundaries(t, cl) + requireInvalid := func(t *testing.T, err error) { + t.Helper() + if !apierrors.IsInvalid(err) { + t.Fatalf("expected API validation rejection, got %v", err) + } + } + newAccess := func(name string) *DNSResolverAccessBinding { + return &DNSResolverAccessBinding{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "validation"}, Spec: DNSResolverAccessBindingSpec{ContextRef: DNSObjectReference{Name: "context", UID: "context-uid"}, Region: "central", DestinationAddress: "fd53::1"}} + } + for _, tc := range []struct { + name string + mutate func(*DNSResolverAccessBinding) + }{ + {"empty-context", func(a *DNSResolverAccessBinding) { a.Spec.ContextRef.Name = "" }}, + {"missing-context-uid", func(a *DNSResolverAccessBinding) { a.Spec.ContextRef.UID = "" }}, + {"empty-region", func(a *DNSResolverAccessBinding) { a.Spec.Region = "" }}, + {"empty-address", func(a *DNSResolverAccessBinding) { a.Spec.DestinationAddress = "" }}, + {"invalid-address", func(a *DNSResolverAccessBinding) { a.Spec.DestinationAddress = "resolver.internal" }}, + } { + t.Run(tc.name, func(t *testing.T) { a := newAccess(tc.name); tc.mutate(a); requireInvalid(t, cl.Create(ctx, a)) }) + } + for _, tc := range []struct { + name string + mutate func(*DNSResolverAccessBinding) + }{ + {"change-context", func(a *DNSResolverAccessBinding) { a.Spec.ContextRef.Name = "other" }}, + {"change-region", func(a *DNSResolverAccessBinding) { a.Spec.Region = "east" }}, + {"change-address", func(a *DNSResolverAccessBinding) { a.Spec.DestinationAddress = "fd53::2" }}, + } { + t.Run(tc.name, func(t *testing.T) { + a := newAccess(tc.name) + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + tc.mutate(a) + requireInvalid(t, cl.Update(ctx, a)) + }) + } + t.Run("metadata-and-status", func(t *testing.T) { + a := newAccess("renewal") + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, a); err != nil { + t.Fatal(err) + } + a.Status.ObservedGeneration = a.Generation + if err := cl.Status().Update(ctx, a); err != nil { + t.Fatalf("access status update failed: %v", err) + } + a.Labels = map[string]string{"test": "metadata"} + if err := cl.Update(ctx, a); err != nil { + t.Fatal(err) + } + }) + t.Run("consumer-identity", func(t *testing.T) { + a := &DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "consumer", Namespace: "validation"}, Spec: DNSResolverContextSpec{ConsumerID: ""}} + requireInvalid(t, cl.Create(ctx, a)) + a.Spec.ConsumerID = "network/application" + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + a.Spec.ConsumerID = "network/other" + requireInvalid(t, cl.Update(ctx, a)) + }) + for _, ttl := range []int32{0, 31, 2147483647} { + t.Run("ttl", func(t *testing.T) { + a := &DNSRegistration{ObjectMeta: metav1.ObjectMeta{GenerateName: "registration-", Namespace: "validation"}, Spec: DNSRegistrationSpec{DNSZoneRef: DNSObjectReference{Name: "zone", UID: "zone-uid"}, Name: "web", RecordTypes: []RRType{RRTypeAAAA}, TTLSeconds: ttl}} + requireInvalid(t, cl.Create(ctx, a)) + }) + } + t.Run("contribution-registration", func(t *testing.T) { + a := &DNSRecordContribution{ObjectMeta: metav1.ObjectMeta{Name: "contribution", Namespace: "validation"}, Spec: DNSRecordContributionSpec{RegistrationRef: DNSObjectReference{Name: "web", UID: "registration-uid", Generation: 1}, RecordSets: []DNSContributionRecordSet{}}} + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + a.Spec.RegistrationRef.Generation = 2 + if err := cl.Update(ctx, a); err != nil { + t.Fatalf("registration generation cannot advance: %v", err) + } + a.Spec.RegistrationRef.Name = "other" + requireInvalid(t, cl.Update(ctx, a)) + }) + t.Run("atomic-contribution-and-stale-update", func(t *testing.T) { + a := &DNSRecordContribution{ObjectMeta: metav1.ObjectMeta{Name: "observations", Namespace: "validation"}, Spec: DNSRecordContributionSpec{RegistrationRef: DNSObjectReference{Name: "web", UID: "registration-uid", Generation: 1}, RecordSets: []DNSContributionRecordSet{}, Eligible: true}} + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + stale := a.DeepCopy() + generation := a.Generation + a.Spec.Eligible = false + a.Spec.Reason = "EndpointUnhealthy" + a.Spec.RecordSets = []DNSContributionRecordSet{{RecordType: RRTypeA, Records: []RecordEntry{{Name: "web", A: &ARecordSpec{Content: "10.0.0.9"}}}}} + if err := cl.Update(ctx, a); err != nil { + t.Fatal(err) + } + if a.Generation != generation+1 { + t.Fatalf("atomic desired state did not advance generation: %d", a.Generation) + } + if err := cl.Update(ctx, stale); !apierrors.IsConflict(err) { + t.Fatalf("stale writer was not rejected: %v", err) + } + a.Status.ObservedGeneration = a.Generation + a.Status.Conditions = []metav1.Condition{{Type: "Published", Status: metav1.ConditionTrue, Reason: "Applied", LastTransitionTime: metav1.Now()}} + if err := cl.Status().Update(ctx, a); err != nil { + t.Fatal(err) + } + var observed DNSRecordContribution + if err := cl.Get(ctx, client.ObjectKeyFromObject(a), &observed); err != nil { + t.Fatal(err) + } + if observed.Spec.Eligible || observed.Spec.RecordSets[0].Records[0].A.Content != "10.0.0.9" { + t.Fatal("status write changed producer desired state") + } + }) + validateReferenceIdentity(t, cl, requireInvalid) + validateRegistrationBounds(t, cl, requireInvalid) +} + +// References may advance a pinned generation, but never retarget another object. +func validateReferenceIdentity(t *testing.T, cl client.Client, requireInvalid func(*testing.T, error)) { + ctx := context.Background() + zone := DNSObjectReference{Name: "zone", UID: "zone-uid", Generation: 1} + resolverContext := DNSObjectReference{Name: "context", UID: "context-uid"} + t.Run("association-reference-identity", func(t *testing.T) { + for name, mutate := range map[string]func(*DNSZoneAssociation){ + "zone-name": func(a *DNSZoneAssociation) { a.Spec.DNSZoneRef.Name = "other" }, + "zone-uid": func(a *DNSZoneAssociation) { a.Spec.DNSZoneRef.UID = "other-uid" }, + "context-name": func(a *DNSZoneAssociation) { a.Spec.ResolverContextRef.Name = "other" }, + "context-uid": func(a *DNSZoneAssociation) { a.Spec.ResolverContextRef.UID = "other-uid" }, + } { + a := &DNSZoneAssociation{ObjectMeta: metav1.ObjectMeta{GenerateName: "association-", Namespace: "validation"}, Spec: DNSZoneAssociationSpec{DNSZoneRef: zone, ResolverContextRef: resolverContext}} + if err := cl.Create(ctx, a); err != nil { + t.Fatal(err) + } + a.Spec.DNSZoneRef.Generation = 2 + if err := cl.Update(ctx, a); err != nil { + t.Fatalf("zone generation cannot advance: %v", err) + } + mutate(a) + t.Run(name, func(t *testing.T) { requireInvalid(t, cl.Update(ctx, a)) }) + } + }) + t.Run("naming-policy-reference-identity", func(t *testing.T) { + for name, mutate := range map[string]func(*DNSNamingPolicy){ + "zone-name": func(p *DNSNamingPolicy) { p.Spec.DNSZoneRef.Name = "other" }, + "zone-uid": func(p *DNSNamingPolicy) { p.Spec.DNSZoneRef.UID = "other-uid" }, + "zone-cleared": func(p *DNSNamingPolicy) { p.Spec.DNSZoneRef = DNSObjectReference{} }, + "context-name": func(p *DNSNamingPolicy) { p.Spec.ResolverContextRef.Name = "other" }, + "context-uid": func(p *DNSNamingPolicy) { p.Spec.ResolverContextRef.UID = "other-uid" }, + } { + p := &DNSNamingPolicy{ObjectMeta: metav1.ObjectMeta{GenerateName: "naming-", Namespace: "validation"}, Spec: DNSNamingPolicySpec{ResolverContextRef: resolverContext, DNSZoneRef: zone, AdditionalNames: []DNSAdditionalNameRule{}}} + if err := cl.Create(ctx, p); err != nil { + t.Fatal(err) + } + p.Spec.Priority = 10 + if err := cl.Update(ctx, p); err != nil { + t.Fatalf("naming policy cannot change unpinned fields: %v", err) + } + mutate(p) + t.Run(name, func(t *testing.T) { requireInvalid(t, cl.Update(ctx, p)) }) + } + p := &DNSNamingPolicy{ObjectMeta: metav1.ObjectMeta{GenerateName: "naming-", Namespace: "validation"}, Spec: DNSNamingPolicySpec{ResolverContextRef: resolverContext, AdditionalNames: []DNSAdditionalNameRule{}}} + if err := cl.Create(ctx, p); err != nil { + t.Fatal(err) + } + p.Spec.DNSZoneRef = zone + t.Run("zone-selected-later", func(t *testing.T) { requireInvalid(t, cl.Update(ctx, p)) }) + }) + t.Run("registration-identity", func(t *testing.T) { + for name, mutate := range map[string]func(*DNSRegistration){ + "name": func(r *DNSRegistration) { r.Spec.Name = "api" }, + "zone-name": func(r *DNSRegistration) { r.Spec.DNSZoneRef.Name = "other" }, + "zone-uid": func(r *DNSRegistration) { r.Spec.DNSZoneRef.UID = "other-uid" }, + } { + r := &DNSRegistration{ObjectMeta: metav1.ObjectMeta{GenerateName: "registration-", Namespace: "validation"}, Spec: DNSRegistrationSpec{DNSZoneRef: zone, Name: "web", RecordTypes: []RRType{RRTypeAAAA}, TTLSeconds: 30}} + if err := cl.Create(ctx, r); err != nil { + t.Fatal(err) + } + r.Spec.TTLSeconds = 10 + r.Spec.DNSZoneRef.Generation = 2 + if err := cl.Update(ctx, r); err != nil { + t.Fatalf("registration cannot change TTL or zone generation: %v", err) + } + mutate(r) + t.Run(name, func(t *testing.T) { requireInvalid(t, cl.Update(ctx, r)) }) + } + }) +} + +func validateRegistrationBounds(t *testing.T, cl client.Client, requireInvalid func(*testing.T, error)) { + ctx := context.Background() + many := func(n int, prefix string) []string { + out := make([]string, n) + for i := range out { + out[i] = prefix + string(rune('a'+i%26)) + string(rune('a'+i/26)) + } + return out + } + for name, mutate := range map[string]func(*DNSRegistrationSpec){ + "empty-name": func(s *DNSRegistrationSpec) { s.Name = "" }, + "name-with-space": func(s *DNSRegistrationSpec) { s.Name = "web app" }, + "long-name": func(s *DNSRegistrationSpec) { s.Name = strings.Repeat("a", 254) }, + "no-record-types": func(s *DNSRegistrationSpec) { s.RecordTypes = nil }, + "duplicate-record-type": func(s *DNSRegistrationSpec) { s.RecordTypes = []RRType{RRTypeA, RRTypeA} }, + "many-reserved": func(s *DNSRegistrationSpec) { s.ReservedDescendants = many(65, "r") }, + } { + t.Run("registration-bounds-"+name, func(t *testing.T) { + r := &DNSRegistration{ObjectMeta: metav1.ObjectMeta{GenerateName: "bounded-", Namespace: "validation"}, Spec: DNSRegistrationSpec{DNSZoneRef: DNSObjectReference{Name: "zone", UID: "zone-uid"}, Name: "web", RecordTypes: []RRType{RRTypeA}, TTLSeconds: 30}} + mutate(&r.Spec) + requireInvalid(t, cl.Create(ctx, r)) + }) + } + t.Run("contribution-bounds", func(t *testing.T) { + records := make([]RecordEntry, 257) + for i := range records { + records[i] = RecordEntry{Name: "web", A: &ARecordSpec{Content: "10.0.0.1"}} + } + c := &DNSRecordContribution{ObjectMeta: metav1.ObjectMeta{GenerateName: "bounded-", Namespace: "validation"}, Spec: DNSRecordContributionSpec{RegistrationRef: DNSObjectReference{Name: "web", UID: "registration-uid", Generation: 1}, RecordSets: []DNSContributionRecordSet{{RecordType: RRTypeA, Records: records}}}} + requireInvalid(t, cl.Create(ctx, c)) + }) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c86213e2..df717379 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -87,6 +87,282 @@ func (in *CNAMERecordSpec) DeepCopy() *CNAMERecordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSAdditionalNameRule) DeepCopyInto(out *DNSAdditionalNameRule) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(DNSRegistrationSelector) + (*in).DeepCopyInto(*out) + } + out.DNSZoneRef = in.DNSZoneRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSAdditionalNameRule. +func (in *DNSAdditionalNameRule) DeepCopy() *DNSAdditionalNameRule { + if in == nil { + return nil + } + out := new(DNSAdditionalNameRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSContributionRecordSet) DeepCopyInto(out *DNSContributionRecordSet) { + *out = *in + if in.Records != nil { + in, out := &in.Records, &out.Records + *out = make([]RecordEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSContributionRecordSet. +func (in *DNSContributionRecordSet) DeepCopy() *DNSContributionRecordSet { + if in == nil { + return nil + } + out := new(DNSContributionRecordSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSNamingPolicy) DeepCopyInto(out *DNSNamingPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSNamingPolicy. +func (in *DNSNamingPolicy) DeepCopy() *DNSNamingPolicy { + if in == nil { + return nil + } + out := new(DNSNamingPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSNamingPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSNamingPolicyList) DeepCopyInto(out *DNSNamingPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSNamingPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSNamingPolicyList. +func (in *DNSNamingPolicyList) DeepCopy() *DNSNamingPolicyList { + if in == nil { + return nil + } + out := new(DNSNamingPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSNamingPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSNamingPolicySpec) DeepCopyInto(out *DNSNamingPolicySpec) { + *out = *in + out.ResolverContextRef = in.ResolverContextRef + if in.AdditionalNames != nil { + in, out := &in.AdditionalNames, &out.AdditionalNames + *out = make([]DNSAdditionalNameRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.DNSZoneRef = in.DNSZoneRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSNamingPolicySpec. +func (in *DNSNamingPolicySpec) DeepCopy() *DNSNamingPolicySpec { + if in == nil { + return nil + } + out := new(DNSNamingPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSNamingPolicyStatus) DeepCopyInto(out *DNSNamingPolicyStatus) { + *out = *in + out.ResolvedResolverContextRef = in.ResolvedResolverContextRef + out.ResolvedDNSZoneRef = in.ResolvedDNSZoneRef + if in.ResolvedAdditionalNames != nil { + in, out := &in.ResolvedAdditionalNames, &out.ResolvedAdditionalNames + *out = make([]DNSResolvedAdditionalNameRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSNamingPolicyStatus. +func (in *DNSNamingPolicyStatus) DeepCopy() *DNSNamingPolicyStatus { + if in == nil { + return nil + } + out := new(DNSNamingPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSObjectReference) DeepCopyInto(out *DNSObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSObjectReference. +func (in *DNSObjectReference) DeepCopy() *DNSObjectReference { + if in == nil { + return nil + } + out := new(DNSObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordContribution) DeepCopyInto(out *DNSRecordContribution) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordContribution. +func (in *DNSRecordContribution) DeepCopy() *DNSRecordContribution { + if in == nil { + return nil + } + out := new(DNSRecordContribution) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecordContribution) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordContributionList) DeepCopyInto(out *DNSRecordContributionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSRecordContribution, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordContributionList. +func (in *DNSRecordContributionList) DeepCopy() *DNSRecordContributionList { + if in == nil { + return nil + } + out := new(DNSRecordContributionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecordContributionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordContributionSpec) DeepCopyInto(out *DNSRecordContributionSpec) { + *out = *in + out.RegistrationRef = in.RegistrationRef + if in.RecordSets != nil { + in, out := &in.RecordSets, &out.RecordSets + *out = make([]DNSContributionRecordSet, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordContributionSpec. +func (in *DNSRecordContributionSpec) DeepCopy() *DNSRecordContributionSpec { + if in == nil { + return nil + } + out := new(DNSRecordContributionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordContributionStatus) DeepCopyInto(out *DNSRecordContributionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordContributionStatus. +func (in *DNSRecordContributionStatus) DeepCopy() *DNSRecordContributionStatus { + if in == nil { + return nil + } + out := new(DNSRecordContributionStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSRecordSet) DeepCopyInto(out *DNSRecordSet) { *out = *in @@ -199,32 +475,580 @@ func (in *DNSRecordSetStatus) DeepCopy() *DNSRecordSetStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DNSZone) DeepCopyInto(out *DNSZone) { +func (in *DNSRegistration) DeepCopyInto(out *DNSRegistration) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZone. -func (in *DNSZone) DeepCopy() *DNSZone { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistration. +func (in *DNSRegistration) DeepCopy() *DNSRegistration { if in == nil { return nil } - out := new(DNSZone) + out := new(DNSRegistration) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DNSZone) DeepCopyObject() runtime.Object { +func (in *DNSRegistration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRegistrationList) DeepCopyInto(out *DNSRegistrationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSRegistration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistrationList. +func (in *DNSRegistrationList) DeepCopy() *DNSRegistrationList { + if in == nil { + return nil + } + out := new(DNSRegistrationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRegistrationList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRegistrationSelector) DeepCopyInto(out *DNSRegistrationSelector) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.MatchExpressions != nil { + in, out := &in.MatchExpressions, &out.MatchExpressions + *out = make([]DNSRegistrationSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistrationSelector. +func (in *DNSRegistrationSelector) DeepCopy() *DNSRegistrationSelector { + if in == nil { + return nil + } + out := new(DNSRegistrationSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRegistrationSelectorRequirement) DeepCopyInto(out *DNSRegistrationSelectorRequirement) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistrationSelectorRequirement. +func (in *DNSRegistrationSelectorRequirement) DeepCopy() *DNSRegistrationSelectorRequirement { + if in == nil { + return nil + } + out := new(DNSRegistrationSelectorRequirement) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRegistrationSpec) DeepCopyInto(out *DNSRegistrationSpec) { + *out = *in + out.DNSZoneRef = in.DNSZoneRef + if in.RecordTypes != nil { + in, out := &in.RecordTypes, &out.RecordTypes + *out = make([]RRType, len(*in)) + copy(*out, *in) + } + if in.ReservedDescendants != nil { + in, out := &in.ReservedDescendants, &out.ReservedDescendants + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistrationSpec. +func (in *DNSRegistrationSpec) DeepCopy() *DNSRegistrationSpec { + if in == nil { + return nil + } + out := new(DNSRegistrationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRegistrationStatus) DeepCopyInto(out *DNSRegistrationStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRegistrationStatus. +func (in *DNSRegistrationStatus) DeepCopy() *DNSRegistrationStatus { + if in == nil { + return nil + } + out := new(DNSRegistrationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolvedAdditionalNameRule) DeepCopyInto(out *DNSResolvedAdditionalNameRule) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + out.DNSZoneRef = in.DNSZoneRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolvedAdditionalNameRule. +func (in *DNSResolvedAdditionalNameRule) DeepCopy() *DNSResolvedAdditionalNameRule { + if in == nil { + return nil + } + out := new(DNSResolvedAdditionalNameRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverAccessBinding) DeepCopyInto(out *DNSResolverAccessBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverAccessBinding. +func (in *DNSResolverAccessBinding) DeepCopy() *DNSResolverAccessBinding { + if in == nil { + return nil + } + out := new(DNSResolverAccessBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSResolverAccessBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverAccessBindingList) DeepCopyInto(out *DNSResolverAccessBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSResolverAccessBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverAccessBindingList. +func (in *DNSResolverAccessBindingList) DeepCopy() *DNSResolverAccessBindingList { + if in == nil { + return nil + } + out := new(DNSResolverAccessBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSResolverAccessBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverAccessBindingSpec) DeepCopyInto(out *DNSResolverAccessBindingSpec) { + *out = *in + out.ContextRef = in.ContextRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverAccessBindingSpec. +func (in *DNSResolverAccessBindingSpec) DeepCopy() *DNSResolverAccessBindingSpec { + if in == nil { + return nil + } + out := new(DNSResolverAccessBindingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverAccessBindingStatus) DeepCopyInto(out *DNSResolverAccessBindingStatus) { + *out = *in + if in.BindingRef != nil { + in, out := &in.BindingRef, &out.BindingRef + *out = new(DNSObjectReference) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverAccessBindingStatus. +func (in *DNSResolverAccessBindingStatus) DeepCopy() *DNSResolverAccessBindingStatus { + if in == nil { + return nil + } + out := new(DNSResolverAccessBindingStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverAccessVersion) DeepCopyInto(out *DNSResolverAccessVersion) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverAccessVersion. +func (in *DNSResolverAccessVersion) DeepCopy() *DNSResolverAccessVersion { + if in == nil { + return nil + } + out := new(DNSResolverAccessVersion) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContext) DeepCopyInto(out *DNSResolverContext) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContext. +func (in *DNSResolverContext) DeepCopy() *DNSResolverContext { + if in == nil { + return nil + } + out := new(DNSResolverContext) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSResolverContext) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextList) DeepCopyInto(out *DNSResolverContextList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSResolverContext, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextList. +func (in *DNSResolverContextList) DeepCopy() *DNSResolverContextList { + if in == nil { + return nil + } + out := new(DNSResolverContextList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSResolverContextList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextManagedNamespace) DeepCopyInto(out *DNSResolverContextManagedNamespace) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextManagedNamespace. +func (in *DNSResolverContextManagedNamespace) DeepCopy() *DNSResolverContextManagedNamespace { + if in == nil { + return nil + } + out := new(DNSResolverContextManagedNamespace) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextManagedNamespaceStatus) DeepCopyInto(out *DNSResolverContextManagedNamespaceStatus) { + *out = *in + out.DNSZoneRef = in.DNSZoneRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextManagedNamespaceStatus. +func (in *DNSResolverContextManagedNamespaceStatus) DeepCopy() *DNSResolverContextManagedNamespaceStatus { + if in == nil { + return nil + } + out := new(DNSResolverContextManagedNamespaceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextServingTarget) DeepCopyInto(out *DNSResolverContextServingTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextServingTarget. +func (in *DNSResolverContextServingTarget) DeepCopy() *DNSResolverContextServingTarget { + if in == nil { + return nil + } + out := new(DNSResolverContextServingTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextSpec) DeepCopyInto(out *DNSResolverContextSpec) { + *out = *in + out.ManagedNamespace = in.ManagedNamespace +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextSpec. +func (in *DNSResolverContextSpec) DeepCopy() *DNSResolverContextSpec { + if in == nil { + return nil + } + out := new(DNSResolverContextSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSResolverContextStatus) DeepCopyInto(out *DNSResolverContextStatus) { + *out = *in + out.ManagedNamespace = in.ManagedNamespace + if in.ServingTargets != nil { + in, out := &in.ServingTargets, &out.ServingTargets + *out = make([]DNSResolverContextServingTarget, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSResolverContextStatus. +func (in *DNSResolverContextStatus) DeepCopy() *DNSResolverContextStatus { + if in == nil { + return nil + } + out := new(DNSResolverContextStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSZone) DeepCopyInto(out *DNSZone) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZone. +func (in *DNSZone) DeepCopy() *DNSZone { + if in == nil { + return nil + } + out := new(DNSZone) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSZone) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSZoneAssociation) DeepCopyInto(out *DNSZoneAssociation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneAssociation. +func (in *DNSZoneAssociation) DeepCopy() *DNSZoneAssociation { + if in == nil { + return nil + } + out := new(DNSZoneAssociation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSZoneAssociation) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSZoneAssociationList) DeepCopyInto(out *DNSZoneAssociationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSZoneAssociation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneAssociationList. +func (in *DNSZoneAssociationList) DeepCopy() *DNSZoneAssociationList { + if in == nil { + return nil + } + out := new(DNSZoneAssociationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSZoneAssociationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSZoneAssociationSpec) DeepCopyInto(out *DNSZoneAssociationSpec) { + *out = *in + out.DNSZoneRef = in.DNSZoneRef + out.ResolverContextRef = in.ResolverContextRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneAssociationSpec. +func (in *DNSZoneAssociationSpec) DeepCopy() *DNSZoneAssociationSpec { + if in == nil { + return nil + } + out := new(DNSZoneAssociationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSZoneAssociationStatus) DeepCopyInto(out *DNSZoneAssociationStatus) { + *out = *in + out.ResolvedDNSZoneRef = in.ResolvedDNSZoneRef + out.ResolvedResolverContextRef = in.ResolvedResolverContextRef + if in.BindingRef != nil { + in, out := &in.BindingRef, &out.BindingRef + *out = new(DNSObjectReference) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneAssociationStatus. +func (in *DNSZoneAssociationStatus) DeepCopy() *DNSZoneAssociationStatus { + if in == nil { + return nil + } + out := new(DNSZoneAssociationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSZoneClass) DeepCopyInto(out *DNSZoneClass) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index fbe725df..cac16db1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -366,7 +366,7 @@ func main() { } if err := setupReplicatorWebhook(mcmgr); err != nil { - setupLog.Error(err, "unable to set up the DNSRecordSet webhook and its checks") + setupLog.Error(err, "unable to set up the admission webhooks and their checks") os.Exit(1) } @@ -405,11 +405,11 @@ func newReplicatorManager( return runnables, mcmgr, nil } -// setupReplicatorWebhook registers the DNSRecordSet webhooks, the -// replicator's health check, and a readiness check on its webhook server. +// setupReplicatorWebhook registers the DNSRecordSet and private DNS webhooks, +// the replicator's health check, and a readiness check on its webhook server. func setupReplicatorWebhook(mcmgr mcmanager.Manager) error { - if err := dnswebhook.SetupDNSRecordSetWebhook(mcmgr); err != nil { - return fmt.Errorf("unable to create the DNSRecordSet webhook: %w", err) + if err := dnswebhook.SetupWebhooks(mcmgr); err != nil { + return fmt.Errorf("unable to create the admission webhooks: %w", err) } if err := mcmgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { return fmt.Errorf("unable to set up health check: %w", err) diff --git a/config/components/admission-webhooks/README.md b/config/components/admission-webhooks/README.md index 1af771fc..d989f38e 100644 --- a/config/components/admission-webhooks/README.md +++ b/config/components/admission-webhooks/README.md @@ -31,6 +31,8 @@ again. A guard that blocked every DNS write while the webhook was unreachable would trade a late condition for the outage in #69, and the per-record `NotOwner` condition still reports anything that slips through. +This component does not register `/validate-internal-dns`, the private DNS reference checks for `DNSZoneAssociation`, `DNSRegistration`, `DNSRecordContribution`, `DNSNamingPolicy` and `DNSResolverAccessBinding`. In deployed environments the internal-dns control plane serves and registers that handler with `failurePolicy: Fail` (the `config/internal-dns` admission added in #238, and infra's `internal-dns-admission`). A second fail-closed registration here would block those writes whenever either server was unavailable. The dns-operator replicator still serves the handler, and `config/webhook` registers it for kind and e2e. + This directory is **not** included in `config/default` or the replicator overlay. Same-cluster packaging for kind/e2e stays under `config/webhook/`. @@ -39,7 +41,7 @@ overlay. Same-cluster packaging for kind/e2e stays under `config/webhook/`. | Kind | Name | Notes | | ---- | ---- | ----- | | `MutatingWebhookConfiguration` | `dns-operator-mutating-webhook-configuration` | `failurePolicy: Fail`; Service ref is `dns-operator-webhook-service` / `datum-dns-system` | -| `ValidatingWebhookConfiguration` | `dns-operator-validating-webhook-configuration` | `failurePolicy: Ignore`; same Service ref | +| `ValidatingWebhookConfiguration` | `dns-operator-validating-webhook-configuration` | `vdnsrecordset.kb.io` is `failurePolicy: Ignore`; same Service ref | No Service and no kustomize `namespace` transformer: Flux `targetNamespace` must not rewrite `clientConfig.service.namespace`. diff --git a/config/components/admission-webhooks/validating-webhook-configuration.yaml b/config/components/admission-webhooks/validating-webhook-configuration.yaml index d126deb2..0e8f9ffd 100644 --- a/config/components/admission-webhooks/validating-webhook-configuration.yaml +++ b/config/components/admission-webhooks/validating-webhook-configuration.yaml @@ -29,3 +29,8 @@ webhooks: resources: - dnsrecordsets scope: "*" + # No /validate-internal-dns entry: in deployed environments the internal-dns + # control plane serves and registers the private DNS reference checks + # (config/internal-dns admission from #238, infra's internal-dns-admission). A second + # fail-closed registration here would block those writes whenever either + # server is unavailable. config/webhook keeps it for kind and e2e. diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsnamingpolicies.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsnamingpolicies.yaml new file mode 100644 index 00000000..6da49ceb --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnsnamingpolicies.yaml @@ -0,0 +1,388 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnsnamingpolicies.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSNamingPolicy + listKind: DNSNamingPolicyList + plural: dnsnamingpolicies + singular: dnsnamingpolicy + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + additionalNames: + items: + properties: + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + namePrefix: + maxLength: 253 + type: string + selector: + description: DNSRegistrationSelector matches registration labels + using Kubernetes selector semantics. + properties: + matchExpressions: + items: + properties: + key: + maxLength: 317 + minLength: 1 + type: string + operator: + description: A label selector operator is the set + of operators that can be used in a selector requirement. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + items: + maxLength: 63 + pattern: ^([A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?)?$ + type: string + maxItems: 64 + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: selector operator requires compatible values + rule: '(self.operator in [''In'', ''NotIn'']) ? (has(self.values) + && self.values.size() > 0) : (!has(self.values) || + self.values.size() == 0)' + maxItems: 64 + type: array + matchLabels: + additionalProperties: + type: string + maxProperties: 64 + type: object + type: object + required: + - dnsZoneRef + - namePrefix + type: object + x-kubernetes-validations: + - message: dnsZoneRef must pin the zone UID + rule: has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > + 0 + maxItems: 256 + type: array + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + priority: + format: int32 + type: integer + resolverContextRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + scopePrefix: + maxLength: 253 + type: string + required: + - additionalNames + - resolverContextRef + type: object + x-kubernetes-validations: + - message: a nonempty resolverContextRef must pin name and UID + rule: '!has(self.resolverContextRef) || ((self.resolverContextRef.name.size() + == 0 && !has(self.resolverContextRef.uid) && (!has(self.resolverContextRef.generation) + || self.resolverContextRef.generation == 0)) || (self.resolverContextRef.name.size() + > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() + > 0))' + - message: a selected dnsZoneRef must pin the zone UID + rule: '!has(self.dnsZoneRef) || self.dnsZoneRef.name.size() == 0 || + (has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0)' + - message: resolverContextRef is required + rule: self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) + && self.resolverContextRef.uid.size() > 0 + - message: resolverContextRef identity is immutable + rule: self.resolverContextRef.name == oldSelf.resolverContextRef.name + && self.resolverContextRef.uid == oldSelf.resolverContextRef.uid + - message: dnsZoneRef identity is immutable + rule: '(has(self.dnsZoneRef) ? self.dnsZoneRef.name : "") == (has(oldSelf.dnsZoneRef) + ? oldSelf.dnsZoneRef.name : "") && ((has(self.dnsZoneRef) && has(self.dnsZoneRef.uid)) + ? self.dnsZoneRef.uid : "") == ((has(oldSelf.dnsZoneRef) && has(oldSelf.dnsZoneRef.uid)) + ? oldSelf.dnsZoneRef.uid : "")' + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + resolvedAdditionalNames: + items: + properties: + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + namePrefix: + type: string + selector: + description: |- + A label selector is a label query over a set of resources. The result of matchLabels and + matchExpressions are ANDed. An empty label selector matches all objects. A null + label selector matches no objects. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - dnsZoneRef + - namePrefix + type: object + type: array + resolvedDNSZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + resolvedResolverContextRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsrecordcontributions.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsrecordcontributions.yaml new file mode 100644 index 00000000..766cbde6 --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnsrecordcontributions.yaml @@ -0,0 +1,400 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnsrecordcontributions.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSRecordContribution + listKind: DNSRecordContributionList + plural: dnsrecordcontributions + singular: dnsrecordcontribution + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + eligible: + type: boolean + reason: + maxLength: 1024 + type: string + recordSets: + items: + properties: + recordType: + enum: + - A + - AAAA + - ALIAS + - CNAME + - TXT + - MX + - SRV + - CAA + - NS + - SOA + - PTR + - TLSA + - HTTPS + - SVCB + type: string + records: + items: + description: RecordEntry represents one owner name and its + values. + properties: + a: + description: Exactly one of the following type-specific + fields should be set matching RecordType. + properties: + content: + format: ipv4 + type: string + required: + - content + type: object + aaaa: + properties: + content: + format: ipv6 + type: string + required: + - content + type: object + alias: + description: |- + ALIASRecordSpec is like CNAME but intended for providers that implement an ALIAS/ANAME-style record. + The content is a hostname (FQDN or relative) with an optional trailing dot. + properties: + content: + maxLength: 253 + minLength: 1 + pattern: ^([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?)(?:\.([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?))*\.?$ + type: string + required: + - content + type: object + caa: + properties: + flag: + description: 0–255 flag + maximum: 255 + minimum: 0 + type: integer + tag: + description: 'RFC-style tags: keep it simple: [a-z0-9]+' + minLength: 1 + pattern: ^[a-z0-9]+$ + type: string + value: + minLength: 1 + type: string + required: + - flag + - tag + - value + type: object + cname: + properties: + content: + maxLength: 253 + minLength: 1 + pattern: ^([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?)(?:\.([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?))*\.?$ + type: string + required: + - content + type: object + https: + properties: + params: + additionalProperties: + type: string + type: object + priority: + maximum: 65535 + minimum: 0 + type: integer + target: + type: string + required: + - priority + - target + type: object + mx: + properties: + exchange: + minLength: 1 + type: string + preference: + maximum: 65535 + minimum: 0 + type: integer + required: + - exchange + - preference + type: object + name: + description: Name is the owner name (relative to the zone + or FQDN). + minLength: 1 + pattern: ^(@|[A-Za-z0-9*._-]+)$ + type: string + ns: + properties: + content: + description: |- + Require a hostname (FQDN or relative), allow optional trailing dot, no underscores. + Labels: 1-63 chars, alphanum with interior hyphens, total length <=253. + maxLength: 253 + minLength: 1 + pattern: ^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\.?$ + type: string + required: + - content + type: object + ptr: + properties: + content: + type: string + required: + - content + type: object + soa: + properties: + expire: + format: int32 + type: integer + mname: + minLength: 1 + type: string + refresh: + format: int32 + type: integer + retry: + format: int32 + type: integer + rname: + minLength: 1 + type: string + serial: + format: int32 + type: integer + ttl: + format: int32 + type: integer + required: + - mname + - rname + type: object + srv: + properties: + port: + maximum: 65535 + minimum: 0 + type: integer + priority: + maximum: 65535 + minimum: 0 + type: integer + target: + minLength: 1 + type: string + weight: + maximum: 65535 + minimum: 0 + type: integer + required: + - port + - priority + - target + - weight + type: object + svcb: + properties: + params: + additionalProperties: + type: string + type: object + priority: + maximum: 65535 + minimum: 0 + type: integer + target: + type: string + required: + - priority + - target + type: object + tlsa: + properties: + certData: + type: string + matchingType: + type: integer + selector: + type: integer + usage: + type: integer + required: + - certData + - matchingType + - selector + - usage + type: object + ttl: + description: TTL optionally overrides TTL for this owner/RRset. + format: int64 + type: integer + txt: + properties: + content: + type: string + required: + - content + type: object + required: + - name + type: object + maxItems: 256 + type: array + required: + - recordType + - records + type: object + maxItems: 14 + type: array + x-kubernetes-list-map-keys: + - recordType + x-kubernetes-list-type: map + registrationRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + required: + - eligible + - recordSets + - registrationRef + type: object + x-kubernetes-validations: + - message: registrationRef requires name, UID, and generation + rule: self.registrationRef.name.size() > 0 && has(self.registrationRef.uid) + && self.registrationRef.uid.size() > 0 && has(self.registrationRef.generation) + && self.registrationRef.generation > 0 + - message: contribution registration identity is immutable + rule: self.registrationRef.name == oldSelf.registrationRef.name && self.registrationRef.uid + == oldSelf.registrationRef.uid + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + publishedRevision: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsregistrations.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsregistrations.yaml new file mode 100644 index 00000000..b98c6c95 --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnsregistrations.yaml @@ -0,0 +1,197 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnsregistrations.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSRegistration + listKind: DNSRegistrationList + plural: dnsregistrations + singular: dnsregistration + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + name: + description: Name is the owner name, relative to the zone or fully + qualified. + maxLength: 253 + minLength: 1 + pattern: ^(@|[A-Za-z0-9*._-]+)$ + type: string + recordTypes: + items: + enum: + - A + - AAAA + - ALIAS + - CNAME + - TXT + - MX + - SRV + - CAA + - NS + - SOA + - PTR + - TLSA + - HTTPS + - SVCB + type: string + maxItems: 14 + minItems: 1 + type: array + x-kubernetes-list-type: set + reservedDescendants: + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set + ttlSeconds: + description: TTLSeconds bounds cached dynamic answers to at most 30 + seconds after withdrawal. + format: int32 + maximum: 30 + minimum: 1 + type: integer + required: + - dnsZoneRef + - name + - recordTypes + - ttlSeconds + type: object + x-kubernetes-validations: + - message: dnsZoneRef must pin the zone UID + rule: has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0 + - message: dnsZoneRef identity is immutable + rule: self.dnsZoneRef.name == oldSelf.dnsZoneRef.name && self.dnsZoneRef.uid + == oldSelf.dnsZoneRef.uid + - message: name is immutable + rule: self.name == oldSelf.name + status: + properties: + canonicalFQDN: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + publicationRevision: + format: int64 + type: integer + publicationWriterEpoch: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsresolveraccessbindings.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsresolveraccessbindings.yaml new file mode 100644 index 00000000..f7ae257d --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnsresolveraccessbindings.yaml @@ -0,0 +1,177 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnsresolveraccessbindings.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSResolverAccessBinding + listKind: DNSResolverAccessBindingList + plural: dnsresolveraccessbindings + singular: dnsresolveraccessbinding + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + contextRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + destinationAddress: + maxLength: 253 + minLength: 1 + type: string + x-kubernetes-validations: + - message: destinationAddress must be an IP address + rule: isIP(self) + region: + maxLength: 253 + minLength: 1 + type: string + required: + - contextRef + - destinationAddress + - region + type: object + x-kubernetes-validations: + - message: contextRef requires name and UID + rule: self.contextRef.name.size() > 0 && has(self.contextRef.uid) && + self.contextRef.uid.size() > 0 + - message: contextRef is immutable + rule: self.contextRef == oldSelf.contextRef + - message: access destination is immutable + rule: self.region == oldSelf.region && self.destinationAddress == oldSelf.destinationAddress + status: + properties: + bindingRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsresolvercontexts.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsresolvercontexts.yaml new file mode 100644 index 00000000..64cc3207 --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnsresolvercontexts.yaml @@ -0,0 +1,163 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnsresolvercontexts.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSResolverContext + listKind: DNSResolverContextList + plural: dnsresolvercontexts + singular: dnsresolvercontext + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + DNSResolverContext is the DNS-owned serving and namespace boundary created by + a trusted network integration. consumerID is opaque to DNS and immutable. + properties: + consumerID: + maxLength: 512 + minLength: 1 + type: string + managedNamespace: + properties: + enabled: + type: boolean + type: object + required: + - consumerID + type: object + x-kubernetes-validations: + - message: consumerID is immutable + rule: oldSelf.consumerID == self.consumerID + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + managedNamespace: + properties: + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + suffix: + type: string + type: object + servingTargets: + items: + properties: + region: + type: string + shard: + type: string + required: + - region + - shard + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszoneassociations.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszoneassociations.yaml new file mode 100644 index 00000000..14653ea2 --- /dev/null +++ b/config/crd/bases/dns.networking.miloapis.com_dnszoneassociations.yaml @@ -0,0 +1,238 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + discovery.miloapis.com/parent-contexts: Project + name: dnszoneassociations.dns.networking.miloapis.com +spec: + group: dns.networking.miloapis.com + names: + kind: DNSZoneAssociation + listKind: DNSZoneAssociationList + plural: dnszoneassociations + singular: dnszoneassociation + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + dnsZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + resolverContextRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + required: + - dnsZoneRef + - resolverContextRef + type: object + x-kubernetes-validations: + - message: a nonempty resolverContextRef must pin name and UID + rule: '!has(self.resolverContextRef) || ((self.resolverContextRef.name.size() + == 0 && !has(self.resolverContextRef.uid) && (!has(self.resolverContextRef.generation) + || self.resolverContextRef.generation == 0)) || (self.resolverContextRef.name.size() + > 0 && has(self.resolverContextRef.uid) && self.resolverContextRef.uid.size() + > 0))' + - message: dnsZoneRef must pin the zone UID + rule: has(self.dnsZoneRef.uid) && self.dnsZoneRef.uid.size() > 0 + - message: resolverContextRef is required + rule: self.resolverContextRef.name.size() > 0 && has(self.resolverContextRef.uid) + && self.resolverContextRef.uid.size() > 0 + - message: dnsZoneRef identity is immutable + rule: self.dnsZoneRef.name == oldSelf.dnsZoneRef.name && self.dnsZoneRef.uid + == oldSelf.dnsZoneRef.uid + - message: resolverContextRef identity is immutable + rule: self.resolverContextRef.name == oldSelf.resolverContextRef.name + && self.resolverContextRef.uid == oldSelf.resolverContextRef.uid + status: + properties: + bindingRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + resolvedDNSZoneRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + resolvedResolverContextRef: + description: |- + DNSObjectReference pins a namespaced object by API-assigned UID. Generation + is used where a writer must also be fenced against policy changes. + properties: + generation: + format: int64 + minimum: 0 + type: integer + name: + maxLength: 253 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + type: string + required: + - name + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 8fc3e9a2..81e6435a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -6,11 +6,19 @@ resources: - bases/dns.networking.miloapis.com_dnszones.yaml - bases/dns.networking.miloapis.com_dnsrecordsets.yaml - bases/dns.networking.miloapis.com_dnszonediscoveries.yaml + + +- bases/dns.networking.miloapis.com_dnsnamingpolicies.yaml +- bases/dns.networking.miloapis.com_dnsrecordcontributions.yaml +- bases/dns.networking.miloapis.com_dnsregistrations.yaml +- bases/dns.networking.miloapis.com_dnsresolveraccessbindings.yaml +- bases/dns.networking.miloapis.com_dnsresolvercontexts.yaml +- bases/dns.networking.miloapis.com_dnszoneassociations.yaml # +kubebuilder:scaffold:crdkustomizeresource -patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD +#patches: # +kubebuilder:scaffold:crdkustomizewebhookpatch # [WEBHOOK] To enable webhook, uncomment the following section diff --git a/config/iam/protected-resources/internal-dns.yaml b/config/iam/protected-resources/internal-dns.yaml new file mode 100644 index 00000000..d39df207 --- /dev/null +++ b/config/iam/protected-resources/internal-dns.yaml @@ -0,0 +1,107 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnszoneassociation +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSZoneAssociation + plural: dnszoneassociations + singular: dnszoneassociation + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnsnamingpolicy +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSNamingPolicy + plural: dnsnamingpolicies + singular: dnsnamingpolicy + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnsregistration +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSRegistration + plural: dnsregistrations + singular: dnsregistration + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnsrecordcontribution +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSRecordContribution + plural: dnsrecordcontributions + singular: dnsrecordcontribution + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnsresolvercontext +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSResolverContext + plural: dnsresolvercontexts + singular: dnsresolvercontext + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: dns.networking.miloapis.com-dnsresolveraccessbinding +spec: + serviceRef: + name: dns.networking.miloapis.com + kind: DNSResolverAccessBinding + plural: dnsresolveraccessbindings + singular: dnsresolveraccessbinding + permissions: [list, get, watch, create, update, patch, delete] + subresources: + - name: status + permissions: [get, update, patch] + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project diff --git a/config/iam/protected-resources/kustomization.yaml b/config/iam/protected-resources/kustomization.yaml index 2df1df9c..c5b52617 100644 --- a/config/iam/protected-resources/kustomization.yaml +++ b/config/iam/protected-resources/kustomization.yaml @@ -10,3 +10,4 @@ resources: - dnsrecordsets.yaml - dnszoneclasses.yaml - dnszonediscoveries.yaml + - internal-dns.yaml diff --git a/config/iam/roles/dns-admin.yaml b/config/iam/roles/dns-admin.yaml index a01343f2..a5a01d5c 100644 --- a/config/iam/roles/dns-admin.yaml +++ b/config/iam/roles/dns-admin.yaml @@ -22,5 +22,15 @@ spec: - dns.networking.miloapis.com/dnszonediscoveries.update - dns.networking.miloapis.com/dnszonediscoveries.patch - dns.networking.miloapis.com/dnszonediscoveries.delete - - + - dns.networking.miloapis.com/dnszoneassociations.create + - dns.networking.miloapis.com/dnszoneassociations.update + - dns.networking.miloapis.com/dnszoneassociations.patch + - dns.networking.miloapis.com/dnszoneassociations.delete + - dns.networking.miloapis.com/dnsnamingpolicies.create + - dns.networking.miloapis.com/dnsnamingpolicies.update + - dns.networking.miloapis.com/dnsnamingpolicies.patch + - dns.networking.miloapis.com/dnsnamingpolicies.delete + - dns.networking.miloapis.com/dnsregistrations.create + - dns.networking.miloapis.com/dnsregistrations.update + - dns.networking.miloapis.com/dnsregistrations.patch + - dns.networking.miloapis.com/dnsregistrations.delete diff --git a/config/iam/roles/dns-viewer.yaml b/config/iam/roles/dns-viewer.yaml index 3bc1124c..84c6a486 100644 --- a/config/iam/roles/dns-viewer.yaml +++ b/config/iam/roles/dns-viewer.yaml @@ -24,5 +24,21 @@ spec: - dns.networking.miloapis.com/dnszonediscoveries.get - dns.networking.miloapis.com/dnszonediscoveries/status.get - dns.networking.miloapis.com/dnszonediscoveries.watch - - + - dns.networking.miloapis.com/dnszoneassociations.list + - dns.networking.miloapis.com/dnszoneassociations.get + - dns.networking.miloapis.com/dnszoneassociations.watch + - dns.networking.miloapis.com/dnsnamingpolicies.list + - dns.networking.miloapis.com/dnsnamingpolicies.get + - dns.networking.miloapis.com/dnsnamingpolicies.watch + - dns.networking.miloapis.com/dnsregistrations.list + - dns.networking.miloapis.com/dnsregistrations.get + - dns.networking.miloapis.com/dnsregistrations.watch + - dns.networking.miloapis.com/dnsrecordcontributions.list + - dns.networking.miloapis.com/dnsrecordcontributions.get + - dns.networking.miloapis.com/dnsrecordcontributions.watch + - dns.networking.miloapis.com/dnsresolvercontexts.list + - dns.networking.miloapis.com/dnsresolvercontexts.get + - dns.networking.miloapis.com/dnsresolvercontexts.watch + - dns.networking.miloapis.com/dnsresolveraccessbindings.list + - dns.networking.miloapis.com/dnsresolveraccessbindings.get + - dns.networking.miloapis.com/dnsresolveraccessbindings.watch diff --git a/config/internal-dns/examples/context.yaml b/config/internal-dns/examples/context.yaml new file mode 100644 index 00000000..337d38c2 --- /dev/null +++ b/config/internal-dns/examples/context.yaml @@ -0,0 +1,11 @@ +apiVersion: dns.networking.miloapis.com/v1alpha1 +kind: DNSResolverContext +metadata: + name: application-network + namespace: project +spec: + # The network integration supplies an opaque, stable consumer identity. + consumerID: network-integration/application-network + # Products discover this namespace instead of asking users to select a zone. + managedNamespace: + enabled: true diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d8c1ac83..f378240c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -56,6 +56,13 @@ rules: - get - patch - update +- apiGroups: + - dns.networking.miloapis.com + resources: + - dnsregistrations + - dnsresolvercontexts + verbs: + - get - apiGroups: - dns.networking.miloapis.com resources: diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index de68d030..ec7e19b0 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -50,3 +50,27 @@ webhooks: resources: - dnsrecordsets sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-internal-dns + failurePolicy: Fail + name: vinternaldns.kb.io + rules: + - apiGroups: + - dns.networking.miloapis.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - dnszoneassociations + - dnsregistrations + - dnsrecordcontributions + - dnsnamingpolicies + - dnsresolveraccessbindings + sideEffects: None diff --git a/internal/internaldns/admission/handler.go b/internal/internaldns/admission/handler.go new file mode 100644 index 00000000..a1cb691a --- /dev/null +++ b/internal/internaldns/admission/handler.go @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package admission validates live references between private DNS resources. +package admission + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + admissionv1 "k8s.io/api/admission/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + kubeadmission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const subresourceStatus = "status" + +const contributionResource = "dnsrecordcontributions" + +// Path is where the dns-operator webhook server serves this handler. +const Path = "/validate-internal-dns" + +// Resources lists the resources whose writes require live reference checks. +// The ValidatingWebhookConfiguration must match exactly these resources. +var Resources = []string{"dnszoneassociations", "dnsregistrations", contributionResource, "dnsnamingpolicies", "dnsresolveraccessbindings"} + +// Handler uses an uncached client selected by authenticated project context. +type Handler struct { + Client client.Client + + ClientForRequest func(context.Context, kubeadmission.Request) (client.Client, error) +} + +var _ kubeadmission.Handler = (*Handler)(nil) + +func (h *Handler) Handle(ctx context.Context, req kubeadmission.Request) kubeadmission.Response { + if req.Resource.Group != dnsv1alpha1.GroupVersion.Group { + return kubeadmission.Denied("unexpected API group") + } + // The API server authorizes resource and status access through RBAC. + if req.Operation == admissionv1.Delete { + return kubeadmission.Allowed("API server authorization applies") + } + if req.SubResource == subresourceStatus { + return kubeadmission.Allowed("API server status authorization applies") + } + if !slices.Contains(Resources, req.Resource.Resource) { + return kubeadmission.Allowed("API server authorization and schema validation apply") + } + if req.Operation == admissionv1.Update { + var object metav1.PartialObjectMetadata + if err := json.Unmarshal(req.Object.Raw, &object); err != nil { + return kubeadmission.Denied("invalid resource metadata") + } + if !object.DeletionTimestamp.IsZero() { + return kubeadmission.Allowed("resource finalization does not require live references") + } + } + cl := h.Client + if h.ClientForRequest != nil { + var err error + cl, err = h.ClientForRequest(ctx, req) + if err != nil { + return kubeadmission.Errored(http.StatusServiceUnavailable, err) + } + } + if cl == nil { + return kubeadmission.Errored(http.StatusServiceUnavailable, fmt.Errorf("project API client unavailable")) + } + var err error + switch req.Resource.Resource { + case "dnsresolveraccessbindings": + err = h.resolverAccess(ctx, cl, req) + case "dnsregistrations": + err = h.registration(ctx, cl, req) + case contributionResource: + err = h.contribution(ctx, cl, req) + case "dnszoneassociations": + err = h.association(ctx, cl, req) + case "dnsnamingpolicies": + err = h.namingPolicy(ctx, cl, req) + default: + return kubeadmission.Allowed("project API authorization applies") + } + if err != nil { + return kubeadmission.Denied(err.Error()) + } + return kubeadmission.Allowed("DNS references are valid") +} + +func (h *Handler) resolverAccess(ctx context.Context, cl client.Client, req kubeadmission.Request) error { + var current dnsv1alpha1.DNSResolverAccessBinding + if err := decode(req, ¤t); err != nil { + return err + } + var resolverContext dnsv1alpha1.DNSResolverContext + if err := cl.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: current.Spec.ContextRef.Name}, &resolverContext); err != nil { + return fmt.Errorf("resolver context lookup: %w", err) + } + if resolverContext.UID != current.Spec.ContextRef.UID || !resolverContext.DeletionTimestamp.IsZero() { + return fmt.Errorf("resolver context lifetime is not current") + } + ready := apimeta.FindStatusCondition(resolverContext.Status.Conditions, "Ready") + if ready == nil || ready.Status != metav1.ConditionTrue || ready.ObservedGeneration != resolverContext.Generation { + return fmt.Errorf("resolver context is not ready for its current generation") + } + return nil +} + +func (h *Handler) contribution(ctx context.Context, cl client.Client, req kubeadmission.Request) error { + var contribution dnsv1alpha1.DNSRecordContribution + if err := decode(req, &contribution); err != nil { + return err + } + var registration dnsv1alpha1.DNSRegistration + if err := cl.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: contribution.Spec.RegistrationRef.Name}, ®istration); err != nil { + return err + } + if registration.UID != contribution.Spec.RegistrationRef.UID || registration.Generation != contribution.Spec.RegistrationRef.Generation || !registration.DeletionTimestamp.IsZero() { + return fmt.Errorf("registration lifetime or generation is not current") + } + zone, err := currentPrivateZone(ctx, cl, req.Namespace, registration.Spec.DNSZoneRef) + if err != nil { + return err + } + for _, set := range contribution.Spec.RecordSets { + if !containsType(registration.Spec.RecordTypes, set.RecordType) { + return fmt.Errorf("record type is outside the registration") + } + for _, record := range set.Records { + if err := model.ValidatePrivateRecordOwner(record.Name, zone.Spec.DomainName, string(set.RecordType)); err != nil { + return err + } + if !registrationCoversOwner(®istration, record.Name, zone.Spec.DomainName) { + return fmt.Errorf("record owner %q is outside the registration", record.Name) + } + } + } + return nil +} + +// registrationCoversOwner mirrors the publisher's name scope so writers get +// immediate feedback: an owner must be the registered name or fall within one +// of its reserved descendants. +func registrationCoversOwner(registration *dnsv1alpha1.DNSRegistration, owner, apex string) bool { + name := zoneRelativeOwner(owner, apex) + if name == zoneRelativeOwner(registration.Spec.Name, apex) { + return true + } + for _, scope := range registration.Spec.ReservedDescendants { + s := zoneRelativeOwner(scope, apex) + if name == s || strings.HasSuffix(name, "."+s) { + return true + } + } + return false +} + +func zoneRelativeOwner(name, apex string) string { + n := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ".")) + a := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(apex), ".")) + if n == a { + return "@" + } + return strings.TrimSuffix(n, "."+a) +} + +func (h *Handler) association(ctx context.Context, cl client.Client, req kubeadmission.Request) error { + var a dnsv1alpha1.DNSZoneAssociation + if err := decode(req, &a); err != nil { + return err + } + if _, err := currentPrivateZone(ctx, cl, req.Namespace, a.Spec.DNSZoneRef); err != nil { + return err + } + + if a.Spec.ResolverContextRef.Name == "" || a.Spec.ResolverContextRef.UID == "" { + return fmt.Errorf("association must pin resolver context name and UID") + } + // Readiness is not required here: the context controller creates the + // managed zone association before the context itself can become Ready. + var resolverContext dnsv1alpha1.DNSResolverContext + if err := cl.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: a.Spec.ResolverContextRef.Name}, &resolverContext); err != nil { + return fmt.Errorf("resolver context lookup: %w", err) + } + if resolverContext.UID != a.Spec.ResolverContextRef.UID || !resolverContext.DeletionTimestamp.IsZero() { + return fmt.Errorf("resolver context lifetime is not current") + } + return nil +} + +func (h *Handler) namingPolicy(ctx context.Context, cl client.Client, req kubeadmission.Request) error { + var p dnsv1alpha1.DNSNamingPolicy + if err := decode(req, &p); err != nil { + return err + } + + if p.Spec.DNSZoneRef.Name != "" { + if _, err := currentPrivateZone(ctx, cl, req.Namespace, p.Spec.DNSZoneRef); err != nil { + return err + } + } + for _, rule := range p.Spec.AdditionalNames { + if _, err := metav1.LabelSelectorAsSelector(rule.Selector.AsLabelSelector()); err != nil { + return fmt.Errorf("invalid naming selector: %w", err) + } + if _, err := currentPrivateZone(ctx, cl, req.Namespace, rule.DNSZoneRef); err != nil { + return err + } + } + + if p.Spec.ResolverContextRef.Name == "" || p.Spec.ResolverContextRef.UID == "" { + return fmt.Errorf("naming policy must pin resolver context name and UID") + } + var resolverContext dnsv1alpha1.DNSResolverContext + if err := cl.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: p.Spec.ResolverContextRef.Name}, &resolverContext); err != nil { + return fmt.Errorf("resolver context lookup: %w", err) + } + ready := apimeta.FindStatusCondition(resolverContext.Status.Conditions, "Ready") + if resolverContext.UID != p.Spec.ResolverContextRef.UID || !resolverContext.DeletionTimestamp.IsZero() || ready == nil || ready.Status != metav1.ConditionTrue || ready.ObservedGeneration != resolverContext.Generation { + return fmt.Errorf("resolver context lifetime is not ready") + } + return nil +} + +func decode(req kubeadmission.Request, obj any) error { + data := req.Object.Raw + if req.Operation == admissionv1.Delete { + data = req.OldObject.Raw + } + if len(data) == 0 { + return fmt.Errorf("admission object is missing") + } + return json.Unmarshal(data, obj) +} + +func containsType(values []dnsv1alpha1.RRType, value dnsv1alpha1.RRType) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func (h *Handler) registration(ctx context.Context, cl client.Client, req kubeadmission.Request) error { + var registration dnsv1alpha1.DNSRegistration + if err := decode(req, ®istration); err != nil { + return err + } + + zone, err := currentPrivateZone(ctx, cl, req.Namespace, registration.Spec.DNSZoneRef) + if err != nil { + return err + } + for _, rt := range registration.Spec.RecordTypes { + if err := model.ValidatePrivateRecordOwner(registration.Spec.Name, zone.Spec.DomainName, string(rt)); err != nil { + return err + } + } + return nil +} + +// Zone references cannot silently follow a replacement with the same name. +func currentPrivateZone(ctx context.Context, cl client.Client, namespace string, ref dnsv1alpha1.DNSObjectReference) (*dnsv1alpha1.DNSZone, error) { + if ref.Name == "" || ref.UID == "" { + return nil, fmt.Errorf("zone reference must pin name and UID") + } + var zone dnsv1alpha1.DNSZone + if err := cl.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &zone); err != nil { + return nil, fmt.Errorf("private zone lookup: %w", err) + } + if zone.UID != ref.UID || !zone.DeletionTimestamp.IsZero() || (ref.Generation != 0 && ref.Generation != zone.Generation) { + return nil, fmt.Errorf("zone lifetime is not current") + } + if zone.Spec.Visibility != dnsv1alpha1.DNSZoneVisibilityPrivate { + return nil, fmt.Errorf("internal references require a private zone") + } + return &zone, nil +} diff --git a/internal/internaldns/admission/handler_test.go b/internal/internaldns/admission/handler_test.go new file mode 100644 index 00000000..d709af52 --- /dev/null +++ b/internal/internaldns/admission/handler_test.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package admission + +import ( + "context" + "encoding/json" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + kubeadmission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestResourceAccessUsesAPIServerAuthorization(t *testing.T) { + h := Handler{} + for _, resource := range []string{"dnsresolvercontexts", "dnsresolverbindings", "dnspublicationmanifests", "dnspublicationchunks", "dnstransportoutboxes", "dnspublicationownerships"} { + if !h.Handle(context.Background(), request(resource, "authorized-user", map[string]any{}, nil)).Allowed { + t.Fatalf("admission duplicates reaccess version for %s", resource) + } + } + req := request("dnsrecordcontributions", "authorized-user", map[string]any{}, nil) + req.SubResource = "status" + if !h.Handle(context.Background(), req).Allowed { + t.Fatal("admission duplicates status authorization") + } +} + +func TestUnavailableProjectContextFailsClosed(t *testing.T) { + h := Handler{} + res := h.Handle(context.Background(), request("dnsrecordcontributions", "publisher", map[string]any{}, nil)) + if res.Allowed || res.Result == nil || res.Result.Code != 503 { + t.Fatalf("must fail closed: %#v", res) + } +} + +func request(resource, username string, obj, old any) kubeadmission.Request { + raw, _ := json.Marshal(obj) + var oldRaw []byte + if old != nil { + oldRaw, _ = json.Marshal(old) + } + return kubeadmission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + UID: types.UID("request-uid"), Namespace: "project", Operation: admissionv1.Update, + Resource: metav1.GroupVersionResource{Group: dnsv1alpha1.GroupVersion.Group, Version: "v1alpha1", Resource: resource}, + Kind: metav1.GroupVersionKind{Group: dnsv1alpha1.GroupVersion.Group, Version: "v1alpha1"}, + UserInfo: authenticationv1.UserInfo{Username: username}, Object: runtime.RawExtension{Raw: raw}, OldObject: runtime.RawExtension{Raw: oldRaw}, + }} +} + +func TestInternalAdmissionPreservesPublicZoneControllerStatus(t *testing.T) { + h := Handler{} + req := kubeadmission.Request{AdmissionRequest: admissionv1.AdmissionRequest{Operation: admissionv1.Update, Resource: metav1.GroupVersionResource{Group: dnsv1alpha1.GroupVersion.Group, Version: "v1alpha1", Resource: "dnszones"}, SubResource: "status", UserInfo: authenticationv1.UserInfo{Username: "system:serviceaccount:dns-system:public-controller"}}} + zone := dnsv1alpha1.DNSZone{Spec: dnsv1alpha1.DNSZoneSpec{Visibility: dnsv1alpha1.DNSZoneVisibilityPublic}} + req.Object.Raw, _ = json.Marshal(zone) + if !h.Handle(context.Background(), req).Allowed { + t.Fatal("internal admission blocked legacy public-zone status") + } + zone.Spec.Visibility = dnsv1alpha1.DNSZoneVisibilityPrivate + req.Object.Raw, _ = json.Marshal(zone) + if !h.Handle(context.Background(), req).Allowed { + t.Fatal("status authorization must use the API server") + } +} + +func TestProductIntentCanBeDeletedWithoutLiveReferences(t *testing.T) { + h := Handler{} + req := kubeadmission.Request{AdmissionRequest: admissionv1.AdmissionRequest{Operation: admissionv1.Delete, Resource: metav1.GroupVersionResource{Group: dnsv1alpha1.GroupVersion.Group, Version: "v1alpha1", Resource: "dnsrecordcontributions"}, UserInfo: authenticationv1.UserInfo{Username: "system:serviceaccount:compute:publisher"}}} + if !h.Handle(context.Background(), req).Allowed { + t.Fatal("contribution cleanup required live references") + } + req.Resource.Resource = "dnspublicationownerships" + if !h.Handle(context.Background(), req).Allowed { + t.Fatal("deletion authorization must use the API server") + } +} + +func TestResolverAccessRequiresCurrentContext(t *testing.T) { + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + resolverContext := &dnsv1alpha1.DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "ctx", Namespace: "project", UID: "ctx-uid", Generation: 1}, Status: dnsv1alpha1.DNSResolverContextStatus{}} + setAdmissionCondition(&resolverContext.Status.Conditions, "Ready", metav1.ConditionTrue, 1, now) + h := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(resolverContext).Build()} + old := &dnsv1alpha1.DNSResolverAccessBinding{Spec: dnsv1alpha1.DNSResolverAccessBindingSpec{ContextRef: dnsv1alpha1.DNSObjectReference{Name: "ctx", UID: "ctx-uid"}, Region: "central", DestinationAddress: "fd70:100::10"}} + current := old.DeepCopy() + req := request("dnsresolveraccessbindings", "system:serviceaccount:network-system:integration", current, old) + if res := h.Handle(context.Background(), req); !res.Allowed { + t.Fatalf("current integration update denied: %v", res.Result) + } + forged := current.DeepCopy() + forged.Spec.ContextRef.UID = "replacement" + req = request("dnsresolveraccessbindings", "system:serviceaccount:network-system:integration", forged, old) + if h.Handle(context.Background(), req).Allowed { + t.Fatal("context lifetime changed in place") + } + staleContext := resolverContext.DeepCopy() + staleContext.Generation = 2 + h.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(staleContext).Build() + req = request("dnsresolveraccessbindings", "system:serviceaccount:network-system:integration", current, old) + if h.Handle(context.Background(), req).Allowed { + t.Fatal("stale context Ready condition authorized access") + } +} + +func setAdmissionCondition(conditions *[]metav1.Condition, conditionType string, status metav1.ConditionStatus, generation int64, now time.Time) { + apimeta.SetStatusCondition(conditions, metav1.Condition{Type: conditionType, Status: status, Reason: "Test", ObservedGeneration: generation, LastTransitionTime: metav1.NewTime(now)}) +} + +func TestPrivateRegistrationRejectsAuthorityOverrides(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", Namespace: "project", UID: "zone-uid"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "corp.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + h := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone).Build()} + for _, tt := range []struct { + owner string + rt dnsv1alpha1.RRType + allowed bool + }{{"api", dnsv1alpha1.RRTypeA, true}, {"api", dnsv1alpha1.RRTypeCNAME, true}, {"@", dnsv1alpha1.RRTypeCNAME, false}, {"CORP.INTERNAL.", dnsv1alpha1.RRTypeCNAME, false}, {"@", dnsv1alpha1.RRTypeSOA, false}} { + registration := dnsv1alpha1.DNSRegistration{Spec: dnsv1alpha1.DNSRegistrationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID}, TTLSeconds: 5, Name: tt.owner, RecordTypes: []dnsv1alpha1.RRType{tt.rt}}} + response := h.Handle(context.Background(), request("dnsregistrations", "tenant", registration, nil)) + if response.Allowed != tt.allowed { + t.Fatalf("owner %s type %s allowed=%v expected=%v", tt.owner, tt.rt, response.Allowed, tt.allowed) + } + } +} + +func TestRegistrationRequiresCurrentZone(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", Namespace: "project", UID: "current"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "corp.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + h := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone).Build()} + for _, tt := range []struct { + uid types.UID + ttl int32 + allowed bool + }{ + {"current", 1, true}, {"current", 30, true}, {"", 5, false}, {"retired", 5, false}, + } { + reg := dnsv1alpha1.DNSRegistration{Spec: dnsv1alpha1.DNSRegistrationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: tt.uid}, TTLSeconds: tt.ttl, Name: "api", RecordTypes: []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeA}}} + if got := h.Handle(context.Background(), request("dnsregistrations", "tenant", reg, nil)); got.Allowed != tt.allowed { + t.Errorf("UID=%q TTL=%d: allowed=%v: %v", tt.uid, tt.ttl, got.Allowed, got.Result) + } + } +} + +func TestContributionValidatesReferencesForRBACAuthorizedCallers(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", Namespace: "project", UID: "zone-uid"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "datum.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + registration := &dnsv1alpha1.DNSRegistration{ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "project", UID: "registration", Generation: 1}, Spec: dnsv1alpha1.DNSRegistrationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID}, Name: "web", RecordTypes: []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeA}}} + handler := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone, registration).Build()} + contribution := &dnsv1alpha1.DNSRecordContribution{Spec: dnsv1alpha1.DNSRecordContributionSpec{RegistrationRef: dnsv1alpha1.DNSObjectReference{Name: registration.Name, UID: registration.UID, Generation: registration.Generation}, RecordSets: []dnsv1alpha1.DNSContributionRecordSet{{RecordType: dnsv1alpha1.RRTypeA, Records: []dnsv1alpha1.RecordEntry{{Name: "web", A: &dnsv1alpha1.ARecordSpec{Content: "10.0.0.8"}}}}}}} + for _, caller := range []string{"compute-publisher", "connect-publisher"} { + if result := handler.Handle(context.Background(), request("dnsrecordcontributions", caller, contribution, nil)); !result.Allowed { + t.Fatalf("valid contribution rejected for %s: %v", caller, result.Result) + } + } + contribution.Spec.RegistrationRef.UID = "replacement" + if result := handler.Handle(context.Background(), request("dnsrecordcontributions", "compute-publisher", contribution, nil)); result.Allowed { + t.Fatal("contribution accepted a mismatched registration lifetime") + } +} + +func TestContributionOwnersStayWithinRegistration(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", Namespace: "project", UID: "zone-uid"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "datum.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + registration := &dnsv1alpha1.DNSRegistration{ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "project", UID: "registration", Generation: 1}, Spec: dnsv1alpha1.DNSRegistrationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID}, Name: "web", ReservedDescendants: []string{"targets.web"}, RecordTypes: []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeA}}} + handler := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone, registration).Build()} + for _, tt := range []struct { + owner string + allowed bool + }{{"web", true}, {"WEB.datum.internal.", true}, {"targets.web", true}, {"a.targets.web.datum.internal", true}, {"api", false}, {"other.web", false}, {"@", false}} { + contribution := &dnsv1alpha1.DNSRecordContribution{Spec: dnsv1alpha1.DNSRecordContributionSpec{RegistrationRef: dnsv1alpha1.DNSObjectReference{Name: registration.Name, UID: registration.UID, Generation: registration.Generation}, RecordSets: []dnsv1alpha1.DNSContributionRecordSet{{RecordType: dnsv1alpha1.RRTypeA, Records: []dnsv1alpha1.RecordEntry{{Name: tt.owner, A: &dnsv1alpha1.ARecordSpec{Content: "10.0.0.8"}}}}}}} + if got := handler.Handle(context.Background(), request("dnsrecordcontributions", "publisher", contribution, nil)); got.Allowed != tt.allowed { + t.Errorf("owner %q: allowed=%v expected=%v: %v", tt.owner, got.Allowed, tt.allowed, got.Result) + } + } +} + +func TestAssociationRequiresCurrentResolverContext(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", Namespace: "project", UID: "zone-uid"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "datum.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + // A context that is not yet Ready is still a valid association target. + resolverContext := &dnsv1alpha1.DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "ctx", Namespace: "project", UID: "ctx-uid", Generation: 1}} + association := func(contextUID types.UID) *dnsv1alpha1.DNSZoneAssociation { + return &dnsv1alpha1.DNSZoneAssociation{Spec: dnsv1alpha1.DNSZoneAssociationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID}, ResolverContextRef: dnsv1alpha1.DNSObjectReference{Name: resolverContext.Name, UID: contextUID}}} + } + h := Handler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone, resolverContext).Build()} + if got := h.Handle(context.Background(), request("dnszoneassociations", "tenant", association("ctx-uid"), nil)); !got.Allowed { + t.Fatalf("current context rejected: %v", got.Result) + } + if h.Handle(context.Background(), request("dnszoneassociations", "tenant", association("replaced"), nil)).Allowed { + t.Fatal("association accepted a replaced resolver context") + } + h.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone).Build() + if h.Handle(context.Background(), request("dnszoneassociations", "tenant", association("ctx-uid"), nil)).Allowed { + t.Fatal("association accepted a missing resolver context") + } + deleting := resolverContext.DeepCopy() + deleting.Finalizers = []string{"test"} + deleting.DeletionTimestamp = &metav1.Time{Time: time.Now()} + h.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(zone, deleting).Build() + if h.Handle(context.Background(), request("dnszoneassociations", "tenant", association("ctx-uid"), nil)).Allowed { + t.Fatal("association accepted a deleting resolver context") + } +} + +func TestDeletingResourceCanRemoveFinalizersWithoutReferences(t *testing.T) { + h := Handler{} + for _, resource := range []string{"dnsregistrations", "dnsrecordcontributions", "dnszoneassociations", "dnsnamingpolicies", "dnsresolveraccessbindings"} { + object := metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &metav1.Time{Time: time.Now()}}} + if res := h.Handle(context.Background(), request(resource, "publisher", object, object)); !res.Allowed { + t.Fatalf("%s finalization required live references: %v", resource, res.Result) + } + } +} diff --git a/internal/internaldns/model/model.go b/internal/internaldns/model/model.go new file mode 100644 index 00000000..1f613b98 --- /dev/null +++ b/internal/internaldns/model/model.go @@ -0,0 +1,543 @@ +// Package model defines the durable wire contract shared by the internal DNS +// control plane and every regional serving replica. +package model + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/netip" + "regexp" + "sort" + "strings" + "time" + + "github.com/miekg/dns" +) + +const APIVersion = "internal.dns.networking.miloapis.com/v1alpha6" + +type Kind string + +const ( + KindServingSnapshot Kind = "ServingSnapshot" + KindPublicationChunk Kind = "PublicationChunk" + KindPublicationManifest Kind = "PublicationManifest" + KindMemberAck Kind = "MemberAck" +) + +// Envelope is the only value written to JetStream. Epoch and Revision are +// repeated outside Payload so consumers can reject stale events before decode. +type Envelope struct { + APIVersion string `json:"apiVersion"` + Kind Kind `json:"kind"` + EventID string `json:"eventID"` + Region string `json:"region"` + Shard string `json:"shard"` + ResourceUID string `json:"resourceUID"` + Epoch uint64 `json:"epoch"` + Revision uint64 `json:"revision"` + PublishedAt time.Time `json:"publishedAt"` + Payload json.RawMessage `json:"payload"` +} + +func NewEnvelope(kind Kind, eventID, region, shard, uid string, epoch, revision uint64, at time.Time, payload any) (Envelope, error) { + b, err := json.Marshal(payload) + if err != nil { + return Envelope{}, fmt.Errorf("marshal %s payload: %w", kind, err) + } + e := Envelope{APIVersion: APIVersion, Kind: kind, EventID: eventID, Region: region, Shard: shard, ResourceUID: uid, Epoch: epoch, Revision: revision, PublishedAt: at.UTC(), Payload: b} + return e, e.Validate() +} + +func (e Envelope) Validate() error { + if e.APIVersion != APIVersion { + return fmt.Errorf("unsupported apiVersion %q", e.APIVersion) + } + switch e.Kind { + case KindServingSnapshot, KindPublicationChunk, KindPublicationManifest, KindMemberAck: + default: + return fmt.Errorf("unsupported kind %q", e.Kind) + } + if e.EventID == "" || e.Region == "" || e.Shard == "" || e.ResourceUID == "" || e.Epoch == 0 || e.Revision == 0 || e.PublishedAt.IsZero() || len(e.Payload) == 0 { + return errors.New("envelope identity, epoch, revision, timestamp, and payload are required") + } + if SafeToken(e.Region) != e.Region || SafeToken(e.Shard) != e.Shard { + return errors.New("region and shard must be safe lowercase identifiers") + } + return nil +} + +type Transport string + +const ( + TransportUDP Transport = "UDP" + TransportTCP Transport = "TCP" +) + +type AccessVersion struct { + Epoch uint64 `json:"epoch"` + Sequence uint64 `json:"sequence"` +} + +type Backend struct { + MemberID string `json:"memberID"` + Address string `json:"address"` + Port uint16 `json:"port"` +} + +type ZoneAttachment struct { + ZoneUID string `json:"zoneUID"` + Apex string `json:"apex"` + RequiredPublicationEpoch uint64 `json:"requiredPublicationEpoch"` + RequiredPublicationRevision uint64 `json:"requiredPublicationRevision"` +} + +// Binding is a logical cache and policy context in shared processes. +type Binding struct { + BindingUID string `json:"bindingUID"` + ProjectUID string `json:"projectUID"` + ContextUID string `json:"contextUID"` + BindingGeneration uint64 `json:"bindingGeneration"` + ConfigurationRevision uint64 `json:"configurationRevision"` + ConsumerAddress string `json:"consumerAddress"` + ClusterAddress string `json:"clusterAddress"` + Port uint16 `json:"port"` + Transports []Transport `json:"transports"` + Zones []ZoneAttachment `json:"zones"` + NodeBackends []Backend `json:"nodeBackends"` + ClusterBackends []Backend `json:"clusterBackends"` + Version AccessVersion `json:"version"` + Tombstone bool `json:"tombstone,omitempty"` +} + +func (b Binding) ViewName() string { + return "context_" + OpaqueToken(b.ProjectUID+"/"+b.ContextUID) + "_g" + fmt.Sprint(b.BindingGeneration) +} + +type Member struct { + MemberID string `json:"memberID"` + ReplicaID string `json:"replicaID,omitempty"` + Role string `json:"role"` +} + +type PublicationRequirement struct { + ZoneUID string `json:"zoneUID"` + Epoch uint64 `json:"epoch"` + Revision uint64 `json:"revision"` +} + +// ServingSnapshot is complete for a shard. Omission therefore means removal, +// but only after the entire snapshot has passed validation and been staged. +type ServingSnapshot struct { + Region string `json:"region"` + Shard string `json:"shard"` + ConfigurationEpoch uint64 `json:"configurationEpoch"` + ConfigurationRevision uint64 `json:"configurationRevision"` + Bindings []Binding `json:"bindings"` + Members []Member `json:"members"` + RequiredPublications []PublicationRequirement `json:"requiredPublications,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` +} + +func (s ServingSnapshot) Validate() error { + if s.Region == "" || s.Shard == "" || s.ConfigurationEpoch == 0 || s.ConfigurationRevision == 0 || s.GeneratedAt.IsZero() { + return errors.New("serving snapshot identity, epoch, revision, and timestamp are required") + } + + seenVIP := map[string]string{} + seenUID := map[string]struct{}{} + seenMembers := map[string]struct{}{} + for _, member := range s.Members { + if member.MemberID == "" || (member.Role != "resolver" && member.Role != "node" && member.Role != "cluster" && member.Role != "regional") { + return fmt.Errorf("invalid serving member %q with role %q", member.MemberID, member.Role) + } + key := member.MemberID + "\x00" + member.ReplicaID + if _, ok := seenMembers[key]; ok { + return fmt.Errorf("duplicate serving member %q replica %q", member.MemberID, member.ReplicaID) + } + seenMembers[key] = struct{}{} + } + seenRequirements := map[string]struct{}{} + for _, requirement := range s.RequiredPublications { + if requirement.ZoneUID == "" || requirement.Epoch == 0 || requirement.Revision == 0 { + return errors.New("required publication has incomplete identity") + } + if _, ok := seenRequirements[requirement.ZoneUID]; ok { + return fmt.Errorf("duplicate required publication %q", requirement.ZoneUID) + } + seenRequirements[requirement.ZoneUID] = struct{}{} + } + for i, b := range s.Bindings { + if b.BindingUID == "" || b.ProjectUID == "" || b.ContextUID == "" || b.BindingGeneration == 0 || b.ConfigurationRevision == 0 { + return fmt.Errorf("binding %d has incomplete identity", i) + } + if _, ok := seenUID[b.BindingUID]; ok { + return fmt.Errorf("duplicate binding UID %q", b.BindingUID) + } + seenUID[b.BindingUID] = struct{}{} + for name, raw := range map[string]string{"consumerAddress": b.ConsumerAddress, "clusterAddress": b.ClusterAddress} { + addr, err := netip.ParseAddr(raw) + if err != nil || addr.IsUnspecified() { + return fmt.Errorf("binding %q invalid %s %q", b.BindingUID, name, raw) + } + if old, ok := seenVIP[raw]; ok && old != b.BindingUID { + return fmt.Errorf("address %s assigned to bindings %q and %q", raw, old, b.BindingUID) + } + seenVIP[raw] = b.BindingUID + } + if b.Port == 0 || len(b.Transports) == 0 || b.Version.Epoch == 0 || b.Version.Sequence == 0 { + return fmt.Errorf("binding %q has incomplete transport or access version", b.BindingUID) + } + if err := validateTransports(b.Transports); err != nil { + return fmt.Errorf("binding %q: %w", b.BindingUID, err) + } + for _, group := range [][]Backend{b.NodeBackends, b.ClusterBackends} { + backendMembers := map[string]string{} + backendAddresses := map[string]string{} + for _, backend := range group { + if backend.MemberID == "" || backend.Port == 0 { + return fmt.Errorf("binding %q has incomplete backend", b.BindingUID) + } + if addr, err := netip.ParseAddr(backend.Address); err != nil || addr.IsUnspecified() { + return fmt.Errorf("binding %q has invalid backend address %q", b.BindingUID, backend.Address) + } + endpoint := netip.MustParseAddr(backend.Address).String() + ":" + fmt.Sprint(backend.Port) + if old, ok := backendMembers[backend.MemberID]; ok && old != endpoint { + return fmt.Errorf("binding %q backend member %q has conflicting endpoints", b.BindingUID, backend.MemberID) + } + if old, ok := backendAddresses[endpoint]; ok && old != backend.MemberID { + return fmt.Errorf("binding %q backend endpoint %q has conflicting member IDs", b.BindingUID, endpoint) + } + backendMembers[backend.MemberID] = endpoint + backendAddresses[endpoint] = backend.MemberID + } + } + zoneApex := map[string]string{} + for _, z := range b.Zones { + if z.ZoneUID == "" || z.RequiredPublicationEpoch == 0 || z.RequiredPublicationRevision == 0 { + return fmt.Errorf("binding %q has incomplete zone attachment", b.BindingUID) + } + apex := AbsoluteName(z.Apex) + if _, ok := dns.IsDomainName(apex); apex == "." || !ok { + return fmt.Errorf("binding %q has invalid zone apex", b.BindingUID) + } + if old, ok := zoneApex[apex]; ok && old != z.ZoneUID { + return fmt.Errorf("binding %q attaches zones %q and %q at %s", b.BindingUID, old, z.ZoneUID, apex) + } + zoneApex[apex] = z.ZoneUID + } + } + return nil +} + +func validateTransports(ts []Transport) error { + seen := map[Transport]bool{} + for _, t := range ts { + if t != TransportUDP && t != TransportTCP { + return fmt.Errorf("unsupported transport %q", t) + } + if seen[t] { + return fmt.Errorf("duplicate transport %q", t) + } + seen[t] = true + } + return nil +} + +type ChunkRef struct { + Index int `json:"index"` + SHA256 string `json:"sha256"` + Size int `json:"size"` +} + +type PublicationManifest struct { + ManifestUID string `json:"manifestUID"` + ZoneUID string `json:"zoneUID"` + Apex string `json:"apex"` + WriterEpoch uint64 `json:"writerEpoch"` + Revision uint64 `json:"revision"` + PreviousRevision uint64 `json:"previousRevision,omitempty"` + Tombstone bool `json:"tombstone,omitempty"` + Chunks []ChunkRef `json:"chunks"` + ContentHash string `json:"contentHash"` + GeneratedAt time.Time `json:"generatedAt"` +} + +func (m PublicationManifest) Validate() error { + if m.ManifestUID == "" || m.ZoneUID == "" || AbsoluteName(m.Apex) == "." || m.WriterEpoch == 0 || m.Revision == 0 || m.GeneratedAt.IsZero() { + return errors.New("publication manifest identity, zone, epoch, revision, and timestamp are required") + } + if m.PreviousRevision >= m.Revision && m.PreviousRevision != 0 { + return errors.New("publication manifest previous revision must precede revision") + } + if _, ok := dns.IsDomainName(AbsoluteName(m.Apex)); !ok { + return errors.New("publication manifest apex is invalid") + } + if m.Tombstone { + if len(m.Chunks) != 0 || m.ContentHash != EmptyContentHash() { + return errors.New("tombstone must have no chunks and the empty content hash") + } + return nil + } + if len(m.Chunks) == 0 || !validHash(m.ContentHash) { + return errors.New("non-tombstone manifest requires chunks and a SHA-256 content hash") + } + refs := append([]ChunkRef(nil), m.Chunks...) + sort.Slice(refs, func(i, j int) bool { return refs[i].Index < refs[j].Index }) + for i, ref := range refs { + if ref.Index != i || ref.Size <= 0 || !validHash(ref.SHA256) { + return fmt.Errorf("invalid chunk reference at ordered index %d", i) + } + } + return nil +} + +type PublicationChunk struct { + ManifestUID string `json:"manifestUID"` + ZoneUID string `json:"zoneUID"` + WriterEpoch uint64 `json:"writerEpoch"` + Revision uint64 `json:"revision"` + Index int `json:"index"` + SHA256 string `json:"sha256"` + Payload []byte `json:"payload"` +} + +func (c PublicationChunk) Validate() error { + if c.ManifestUID == "" || c.ZoneUID == "" || c.WriterEpoch == 0 || c.Revision == 0 || c.Index < 0 || len(c.Payload) == 0 || !validHash(c.SHA256) { + return errors.New("publication chunk identity, revision, hash, and payload are required") + } + if Hash(c.Payload) != c.SHA256 { + return errors.New("publication chunk SHA-256 mismatch") + } + return nil +} + +type RRRecord struct { + Content string `json:"content"` + ContributionUID string `json:"contributionUID,omitempty"` + Generation uint64 `json:"generation,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + Eligible bool `json:"eligible"` +} + +type RRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL uint32 `json:"ttl"` + Records []RRRecord `json:"records"` +} + +type PublicationPlan struct { + ZoneUID string `json:"zoneUID"` + Apex string `json:"apex"` + Registrations []RegistrationFence `json:"registrations,omitempty"` + ObservationFences []ObservationFence `json:"observationFences,omitempty"` + Owners []string `json:"owners,omitempty"` + RRSets []RRSet `json:"rrsets"` +} + +type RegistrationFence struct { + UID string `json:"uid"` + Generation uint64 `json:"generation"` +} + +// ObservationFence carries the accepted high-water mark, including withdrawn observations. +type ObservationFence struct { + ContributionUID string `json:"contributionUID"` + Generation uint64 `json:"generation"` + Sequence uint64 `json:"sequence"` + Retired bool `json:"retired,omitempty"` + SourceName string `json:"sourceName,omitempty"` +} + +func (p PublicationPlan) Validate() error { + if p.ZoneUID == "" || AbsoluteName(p.Apex) == "." { + return errors.New("publication plan zone identity is required") + } + apex := AbsoluteName(p.Apex) + if _, ok := dns.IsDomainName(apex); !ok { + return errors.New("publication plan apex is invalid") + } + previousRegistration := "" + for _, registration := range p.Registrations { + if registration.UID == "" || registration.Generation == 0 { + return errors.New("publication registration fence is incomplete") + } + if previousRegistration != "" && registration.UID <= previousRegistration { + return errors.New("publication registration fences must be unique and sorted by UID") + } + previousRegistration = registration.UID + } + observationByUID := make(map[string]ObservationFence, len(p.ObservationFences)) + previousContribution := "" + for _, observation := range p.ObservationFences { + if observation.ContributionUID == "" || observation.Generation == 0 || observation.Sequence == 0 { + return errors.New("publication observation fence is incomplete") + } + if previousContribution != "" && observation.ContributionUID <= previousContribution { + return errors.New("publication observation fences must be unique and sorted by contribution UID") + } + previousContribution = observation.ContributionUID + observationByUID[observation.ContributionUID] = observation + } + for _, owner := range p.Owners { + owner = AbsoluteName(owner) + if _, ok := dns.IsDomainName(owner); !ok || !dns.IsSubDomain(apex, owner) { + return fmt.Errorf("owned name %q is outside zone %q", owner, apex) + } + } + seen := map[string]struct{}{} + for _, rr := range p.RRSets { + owner := strings.ToLower(AbsoluteName(rr.Name)) + rrType := strings.ToUpper(rr.Type) + key := owner + "\x00" + rrType + if rr.TTL == 0 || len(rr.Records) == 0 || rr.Type == "" { + return fmt.Errorf("RRset %q is incomplete", key) + } + if _, ok := dns.IsDomainName(owner); !ok || !dns.IsSubDomain(apex, owner) { + return fmt.Errorf("RRset owner %q is outside zone %q", owner, apex) + } + if _, ok := dns.StringToType[rrType]; !ok { + return fmt.Errorf("RRset %q has unsupported type %q", owner, rr.Type) + } + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate RRset %q", key) + } + seen[key] = struct{}{} + for _, rec := range rr.Records { + if rec.Content == "" { + return fmt.Errorf("RRset %q contains an invalid record", key) + } + if rec.ContributionUID == "" { + if rec.Generation != 0 || rec.Sequence != 0 { + return fmt.Errorf("RRset %q static record contains contribution fence data", key) + } + } else { + observation, ok := observationByUID[rec.ContributionUID] + if !ok || observation.Retired || rec.Generation != observation.Generation || rec.Sequence != observation.Sequence { + return fmt.Errorf("RRset %q record fence does not match contribution observation %q", key, rec.ContributionUID) + } + } + if _, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", owner, rr.TTL, rrType, rec.Content)); err != nil { + return fmt.Errorf("RRset %q contains invalid RDATA: %w", key, err) + } + } + } + return nil +} + +type AckPhase string + +const ( + AckAccepted AckPhase = "Accepted" + AckApplied AckPhase = "Applied" + AckVerified AckPhase = "Verified" + AckExpired AckPhase = "Expired" + AckRejected AckPhase = "Rejected" +) + +type MemberAck struct { + MemberID string `json:"memberID"` + ReplicaID string `json:"replicaID,omitempty"` + ResourceUID string `json:"resourceUID"` + Kind Kind `json:"kind"` + Epoch uint64 `json:"epoch"` + Revision uint64 `json:"revision"` + Phase AckPhase `json:"phase"` + ObservedAt time.Time `json:"observedAt"` + ValidUntil time.Time `json:"validUntil,omitempty"` + Error string `json:"error,omitempty"` +} + +func Hash(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func EmptyContentHash() string { return Hash(nil) } + +func VerifyManifest(m PublicationManifest, chunks []PublicationChunk) ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + if m.Tombstone { + return nil, nil + } + if len(chunks) != len(m.Chunks) { + return nil, fmt.Errorf("manifest references %d chunks but %d were supplied", len(m.Chunks), len(chunks)) + } + byIndex := make(map[int]PublicationChunk, len(chunks)) + for _, c := range chunks { + if err := c.Validate(); err != nil { + return nil, err + } + if c.ManifestUID != m.ManifestUID || c.ZoneUID != m.ZoneUID || c.WriterEpoch != m.WriterEpoch || c.Revision != m.Revision { + return nil, fmt.Errorf("chunk %d identity does not match manifest", c.Index) + } + if _, exists := byIndex[c.Index]; exists { + return nil, fmt.Errorf("duplicate chunk index %d", c.Index) + } + byIndex[c.Index] = c + } + var payload []byte + for _, ref := range m.Chunks { + c, ok := byIndex[ref.Index] + if !ok || len(c.Payload) != ref.Size || c.SHA256 != ref.SHA256 { + return nil, fmt.Errorf("chunk %d is missing or differs from manifest", ref.Index) + } + payload = append(payload, c.Payload...) + } + if Hash(payload) != m.ContentHash { + return nil, errors.New("publication content SHA-256 mismatch") + } + return payload, nil +} + +func Compare(epoch, revision, currentEpoch, currentRevision uint64) int { + if epoch < currentEpoch || (epoch == currentEpoch && revision < currentRevision) { + return -1 + } + if epoch == currentEpoch && revision == currentRevision { + return 0 + } + return 1 +} + +func AbsoluteName(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "." + } + if !strings.HasSuffix(s, ".") { + s += "." + } + return s +} + +var unsafeToken = regexp.MustCompile(`[^a-z0-9_-]+`) + +func SafeToken(s string) string { + s = unsafeToken.ReplaceAllString(strings.ToLower(s), "_") + s = strings.Trim(s, "_") + if s == "" { + return "unknown" + } + return s +} + +// OpaqueToken avoids placing customer-controlled identifiers in NATS subjects. +func OpaqueToken(uid string) string { + sum := sha256.Sum256([]byte(uid)) + return hex.EncodeToString(sum[:12]) +} + +func validHash(s string) bool { + if len(s) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} diff --git a/internal/internaldns/model/model_test.go b/internal/internaldns/model/model_test.go new file mode 100644 index 00000000..470101be --- /dev/null +++ b/internal/internaldns/model/model_test.go @@ -0,0 +1,93 @@ +package model + +import ( + "encoding/json" + "testing" + "time" +) + +func TestVerifyManifestRequiresEveryExactChunk(t *testing.T) { + t.Parallel() + plan := PublicationPlan{ZoneUID: "zone-a", Apex: "internal.example", RRSets: []RRSet{{Name: "api.internal.example", Type: "A", TTL: 30, Records: []RRRecord{{Content: "192.0.2.1", Eligible: true}}}}} + payload, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + cut := len(payload) / 2 + parts := [][]byte{payload[:cut], payload[cut:]} + chunks := make([]PublicationChunk, 0, 2) + refs := make([]ChunkRef, 0, 2) + for i, p := range parts { + chunks = append(chunks, PublicationChunk{ManifestUID: "manifest-a", ZoneUID: "zone-a", WriterEpoch: 3, Revision: 9, Index: i, SHA256: Hash(p), Payload: p}) + refs = append(refs, ChunkRef{Index: i, SHA256: Hash(p), Size: len(p)}) + } + m := PublicationManifest{ManifestUID: "manifest-a", ZoneUID: "zone-a", Apex: "internal.example", WriterEpoch: 3, Revision: 9, Chunks: refs, ContentHash: Hash(payload), GeneratedAt: time.Now()} + got, err := VerifyManifest(m, chunks) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatalf("payload differs") + } + if _, err := VerifyManifest(m, chunks[:1]); err == nil { + t.Fatal("missing chunk accepted") + } + chunks[1].Payload[0] ^= 1 + if _, err := VerifyManifest(m, chunks); err == nil { + t.Fatal("corrupt chunk accepted") + } +} + +func TestServingSnapshotRejectsSharedDestination(t *testing.T) { + t.Parallel() + now := time.Now() + b := func(uid, vpc string) Binding { + return Binding{BindingUID: uid, ProjectUID: "project", ContextUID: vpc, BindingGeneration: 1, ConfigurationRevision: 1, ConsumerAddress: "2001:db8::1", ClusterAddress: "2001:db8::2", Port: 53, Transports: []Transport{TransportUDP, TransportTCP}, Version: AccessVersion{Epoch: 1, Sequence: 1}} + } + s := ServingSnapshot{Region: "east", Shard: "s1", ConfigurationEpoch: 1, ConfigurationRevision: 1, GeneratedAt: now, Bindings: []Binding{b("one", "vpc-a"), b("two", "vpc-b")}} + if err := s.Validate(); err == nil { + t.Fatal("duplicate trusted destination accepted") + } +} + +func TestServingSnapshotRejectsUnknownMemberRole(t *testing.T) { + s := ServingSnapshot{Region: "east", Shard: "s1", ConfigurationEpoch: 1, ConfigurationRevision: 1, GeneratedAt: time.Now(), Members: []Member{{MemberID: "old", Role: "unassigned"}}} + if s.Validate() == nil { + t.Fatal("unknown serving role accepted") + } +} + +func TestCompareFencesOldEpoch(t *testing.T) { + t.Parallel() + if Compare(2, 1000, 3, 1) >= 0 { + t.Fatal("old epoch won by revision") + } + if Compare(4, 1, 3, 1000) <= 0 { + t.Fatal("new epoch lost") + } +} + +func TestViewNameScopesContextByProject(t *testing.T) { + a := Binding{ProjectUID: "project-a", ContextUID: "remote-context", BindingGeneration: 1} + b := a + b.ProjectUID = "project-b" + if a.ViewName() == b.ViewName() { + t.Fatal("identical remote context UID shared a cache namespace across projects") + } + b = a + b.BindingGeneration = 2 + if a.ViewName() == b.ViewName() { + t.Fatal("new binding generation reused an old cache namespace") + } +} + +func TestEnvelopeRejectsObsoleteWireVersion(t *testing.T) { + current, err := NewEnvelope(KindMemberAck, "event", "central", "shared", "uid", 1, 1, time.Now(), MemberAck{MemberID: "member"}) + if err != nil { + t.Fatal(err) + } + current.APIVersion = "internal.dns.networking.miloapis.com/v1alpha1" + if current.Validate() == nil { + t.Fatal("obsolete transport version accepted") + } +} diff --git a/internal/internaldns/model/private_records.go b/internal/internaldns/model/private_records.go new file mode 100644 index 00000000..e0a6d9af --- /dev/null +++ b/internal/internaldns/model/private_records.go @@ -0,0 +1,22 @@ +package model + +import ( + "fmt" + "strings" +) + +// ValidatePrivateRecordOwner reserves the authority metadata generated by the +// regional BIND fleet. It applies only to internal DNS publication inputs. +func ValidatePrivateRecordOwner(owner, apex, recordType string) error { + owner = strings.TrimSpace(owner) + if strings.EqualFold(recordType, "ALIAS") { + return fmt.Errorf("private DNS does not support ALIAS flattening") + } + if strings.EqualFold(recordType, "SOA") { + return fmt.Errorf("private zone SOA records are managed by the DNS service") + } + if strings.EqualFold(recordType, "CNAME") && (owner == "" || owner == "@" || AbsoluteName(owner) == AbsoluteName(apex)) { + return fmt.Errorf("a private zone apex cannot be a CNAME") + } + return nil +} diff --git a/internal/internaldns/model/snapshot_keys.go b/internal/internaldns/model/snapshot_keys.go new file mode 100644 index 00000000..9a6214d4 --- /dev/null +++ b/internal/internaldns/model/snapshot_keys.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package model + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "strings" +) + +func SnapshotPrefix(region, shard string) string { + return SafeToken(region) + "." + SafeToken(shard) + "." +} + +func PublicationActivationName(manifest, region, shard string) string { + name := manifest + "-" + SafeToken(region) + "-" + SafeToken(shard) + "-activate" + if len(name) <= 63 { + return strings.Trim(name, "-") + } + sum := sha256.Sum256([]byte(name)) + return strings.Trim(name[:50], "-") + fmt.Sprintf("-%x", sum[:6]) +} + +// Chunk keys retain a complete immutable revision until its manifest head has +// moved and the supported replay window has elapsed. A manifest head is written +// only after every referenced chunk is durable in both JetStream and bootstrap. +func SnapshotKey(env Envelope) (string, error) { + prefix := SnapshotPrefix(env.Region, env.Shard) + uid := OpaqueToken(env.ResourceUID) + switch env.Kind { + case KindPublicationChunk: + var c PublicationChunk + if err := json.Unmarshal(env.Payload, &c); err != nil { + return "", err + } + return fmt.Sprintf("%schunk.%s.e%d.r%d.i%d", prefix, uid, env.Epoch, env.Revision, c.Index), nil + case KindPublicationManifest: + return prefix + "manifest." + uid, nil + case KindServingSnapshot: + return prefix + "serving." + uid, nil + default: + return "", fmt.Errorf("event %s is not bootstrap state", env.Kind) + } +} diff --git a/internal/internaldns/model/subjects.go b/internal/internaldns/model/subjects.go new file mode 100644 index 00000000..783f0db0 --- /dev/null +++ b/internal/internaldns/model/subjects.go @@ -0,0 +1,23 @@ +package model + +import "fmt" + +func ServingSubject(region, shard string) string { + return fmt.Sprintf("dns.private.serving.%s.%s", SafeToken(region), SafeToken(shard)) +} + +func RecordChunkSubject(region, shard, zoneUID string) string { + return fmt.Sprintf("dns.private.records.%s.%s.%s.chunk", SafeToken(region), SafeToken(shard), OpaqueToken(zoneUID)) +} + +func RecordManifestSubject(region, shard, zoneUID string) string { + return fmt.Sprintf("dns.private.records.%s.%s.%s.manifest", SafeToken(region), SafeToken(shard), OpaqueToken(zoneUID)) +} + +func RecordConsumerFilter(region, shard string) string { + return fmt.Sprintf("dns.private.records.%s.%s.>", SafeToken(region), SafeToken(shard)) +} + +func AckSubject(region, shard, memberID string) string { + return fmt.Sprintf("dns.private.acks.%s.%s.%s", SafeToken(region), SafeToken(shard), OpaqueToken(memberID)) +} diff --git a/internal/webhook/internaldns.go b/internal/webhook/internaldns.go new file mode 100644 index 00000000..438b69cc --- /dev/null +++ b/internal/webhook/internaldns.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package webhook + +import ( + "context" + "fmt" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + + internaldnsadmission "go.miloapis.com/dns-operator/internal/internaldns/admission" +) + +// The private DNS reference checks fail closed: a write that skipped them +// could bind a registration, contribution, association, naming policy, or +// access binding to another tenant's or a replaced object. This marker +// registers them for kind and e2e only; in deployed environments the +// internal-dns control plane registers the same path. +// +kubebuilder:webhook:path=/validate-internal-dns,mutating=false,failurePolicy=fail,sideEffects=None,groups=dns.networking.miloapis.com,resources=dnszoneassociations;dnsregistrations;dnsrecordcontributions;dnsnamingpolicies;dnsresolveraccessbindings,verbs=create;update,versions=v1alpha1,name=vinternaldns.kb.io,admissionReviewVersions=v1 +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones;dnsregistrations;dnsresolvercontexts,verbs=get + +// SetupWebhooks registers every admission handler the dns-operator serves. +func SetupWebhooks(mgr mcmanager.Manager) error { + if err := SetupDNSRecordSetWebhook(mgr); err != nil { + return err + } + SetupInternalDNSWebhook(mgr) + return nil +} + +// SetupInternalDNSWebhook serves the private DNS reference checks at +// internaldnsadmission.Path. +func SetupInternalDNSWebhook(mgr mcmanager.Manager) { + mgr.GetWebhookServer().Register(internaldnsadmission.Path, &admission.Webhook{ + Handler: &internaldnsadmission.Handler{ClientForRequest: liveProjectClient(mgr)}, + }) +} + +// liveProjectClient selects the project control plane named by the request's +// cluster context and reads it without a cache, since the checks compare UIDs +// and generations a cache could serve stale. A request without project context +// reads the local cluster. A named project that cannot be found fails the +// request instead of falling back to another cluster. +func liveProjectClient(mgr mcmanager.Manager) func(context.Context, admission.Request) (client.Client, error) { + return func(ctx context.Context, _ admission.Request) (client.Client, error) { + clusterName, ok := mccontext.ClusterFrom(ctx) + if !ok || clusterName == "" { + local := mgr.GetLocalManager() + return liveReads{Client: local.GetClient(), reader: local.GetAPIReader()}, nil + } + cl, err := mgr.GetCluster(ctx, clusterName) + if err != nil && !strings.HasPrefix(clusterName.String(), "/") { + cl, err = mgr.GetCluster(ctx, "/"+clusterName) + } + if err != nil { + return nil, fmt.Errorf("project control plane %q is unavailable: %w", clusterName, err) + } + return liveReads{Client: cl.GetClient(), reader: cl.GetAPIReader()}, nil + } +} + +// liveReads sends reads to the API server instead of the informer cache. +type liveReads struct { + client.Client + reader client.Reader +} + +func (l liveReads) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return l.reader.Get(ctx, key, obj, opts...) +} + +func (l liveReads) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return l.reader.List(ctx, list, opts...) +} diff --git a/internal/webhook/internaldns_manifest_test.go b/internal/webhook/internaldns_manifest_test.go new file mode 100644 index 00000000..fc999a51 --- /dev/null +++ b/internal/webhook/internaldns_manifest_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package webhook + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "slices" + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + internaldnsadmission "go.miloapis.com/dns-operator/internal/internaldns/admission" +) + +// The kind and e2e registration must send every private DNS reference write to +// the handler and fail closed. +func TestInternalDNSWebhookIsRegisteredFailClosed(t *testing.T) { + path := filepath.Join("..", "..", "config", "webhook", "manifests.yaml") + hook, ok := findValidatingWebhook(t, path, internaldnsadmission.Path) + if !ok { + t.Fatalf("%s registers no validating webhook for %s", path, internaldnsadmission.Path) + } + if hook.FailurePolicy == nil || *hook.FailurePolicy != admissionregistrationv1.Fail { + t.Errorf("failurePolicy = %v, want Fail", hook.FailurePolicy) + } + if len(hook.Rules) != 1 { + t.Fatalf("rules = %d, want 1", len(hook.Rules)) + } + rule := hook.Rules[0] + if !slices.Equal(rule.APIGroups, []string{dnsv1alpha1.GroupVersion.Group}) { + t.Errorf("apiGroups = %v", rule.APIGroups) + } + ops := []admissionregistrationv1.OperationType{admissionregistrationv1.Create, admissionregistrationv1.Update} + if !slices.Equal(rule.Operations, ops) { + t.Errorf("operations = %v, want %v", rule.Operations, ops) + } + got := slices.Sorted(slices.Values(rule.Resources)) + want := slices.Sorted(slices.Values(internaldnsadmission.Resources)) + if !slices.Equal(got, want) { + t.Errorf("resources = %v, want the handled resources %v", got, want) + } +} + +// In deployed environments the internal-dns control plane registers the +// private DNS reference checks. A second fail-closed registration from the +// control-plane component would block those writes whenever either server is +// unavailable. +func TestDeployedComponentLeavesInternalDNSWebhookToControlPlane(t *testing.T) { + path := filepath.Join("..", "..", "config", "components", "admission-webhooks", "validating-webhook-configuration.yaml") + if _, ok := findValidatingWebhook(t, path, internaldnsadmission.Path); ok { + t.Fatalf("%s registers %s; the internal-dns control plane owns that registration", path, internaldnsadmission.Path) + } +} + +func findValidatingWebhook(t *testing.T, path, servicePath string) (admissionregistrationv1.ValidatingWebhook, bool) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decoder := utilyaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + found := false + for { + var config admissionregistrationv1.ValidatingWebhookConfiguration + if err := decoder.Decode(&config); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatalf("decoding %s: %v", path, err) + } + if config.Kind != "ValidatingWebhookConfiguration" { + continue + } + found = true + for _, hook := range config.Webhooks { + if hook.ClientConfig.Service != nil && hook.ClientConfig.Service.Path != nil && *hook.ClientConfig.Service.Path == servicePath { + return hook, true + } + } + } + if !found { + t.Fatalf("%s contains no ValidatingWebhookConfiguration", path) + } + return admissionregistrationv1.ValidatingWebhook{}, false +} diff --git a/internal/webhook/server_test.go b/internal/webhook/server_test.go index 92917d0d..d6e60005 100644 --- a/internal/webhook/server_test.go +++ b/internal/webhook/server_test.go @@ -16,6 +16,7 @@ import ( "time" admissionv1 "k8s.io/api/admission/v1" + authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -33,9 +34,11 @@ import ( const ( mutatePath = "/mutate-dns-networking-miloapis-com-v1alpha1-dnsrecordset" validatePath = "/validate-dns-networking-miloapis-com-v1alpha1-dnsrecordset" + // Kept literal so the test fails if the served path drifts from the manifests. + internalDNSPath = "/validate-internal-dns" ) -func TestSetupDNSRecordSetWebhook_IsServed(t *testing.T) { +func TestSetupWebhooks_AreServed(t *testing.T) { scheme := runtime.NewScheme() if err := dnsv1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("AddToScheme: %v", err) @@ -75,8 +78,8 @@ func TestSetupDNSRecordSetWebhook_IsServed(t *testing.T) { if err != nil { t.Fatalf("creating multicluster manager: %v", err) } - if err := SetupDNSRecordSetWebhook(mgr); err != nil { - t.Fatalf("SetupDNSRecordSetWebhook: %v", err) + if err := SetupWebhooks(mgr); err != nil { + t.Fatalf("SetupWebhooks: %v", err) } ctx, cancel := context.WithCancel(context.Background()) @@ -167,6 +170,64 @@ func TestSetupDNSRecordSetWebhook_IsServed(t *testing.T) { } }) + privateZone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: "private-zone", Namespace: "default"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "internal.example", DNSZoneClassName: "private", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}, + } + if err := k8sClient.Create(ctx, privateZone); err != nil { + t.Fatalf("creating private DNSZone: %v", err) + } + registrationReview := func(zoneUID types.UID, extra map[string]authv1.ExtraValue) admissionv1.AdmissionReview { + registration := &dnsv1alpha1.DNSRegistration{ + ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "default"}, + Spec: dnsv1alpha1.DNSRegistrationSpec{ + DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: privateZone.Name, UID: zoneUID}, + Name: "web", + RecordTypes: []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeA}, + TTLSeconds: 30, + }, + } + raw, err := json.Marshal(registration) + if err != nil { + t.Fatalf("marshalling DNSRegistration: %v", err) + } + return admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{APIVersion: "admission.k8s.io/v1", Kind: "AdmissionReview"}, + Request: &admissionv1.AdmissionRequest{ + UID: "internal-dns-uid", + Operation: admissionv1.Create, + Namespace: registration.Namespace, + Name: registration.Name, + Kind: metav1.GroupVersionKind{Group: dnsv1alpha1.GroupVersion.Group, Version: dnsv1alpha1.GroupVersion.Version, Kind: "DNSRegistration"}, + Resource: metav1.GroupVersionResource{Group: dnsv1alpha1.GroupVersion.Group, Version: dnsv1alpha1.GroupVersion.Version, Resource: "dnsregistrations"}, + UserInfo: authv1.UserInfo{Username: "project-admin", Extra: extra}, + Object: runtime.RawExtension{Raw: raw}, + }, + } + } + + t.Run("internal DNS path admits a live private zone reference", func(t *testing.T) { + resp := sendAdmissionReview(ctx, t, httpClient, endpoint(internalDNSPath), registrationReview(privateZone.UID, nil)) + if !resp.Allowed { + t.Fatalf("internal DNS webhook refused a live private zone reference: %v", resp.Result) + } + }) + + t.Run("internal DNS path refuses a replaced zone", func(t *testing.T) { + resp := sendAdmissionReview(ctx, t, httpClient, endpoint(internalDNSPath), registrationReview("replaced-zone-uid", nil)) + if resp.Allowed { + t.Fatal("internal DNS webhook admitted a reference to a replaced zone") + } + }) + + t.Run("internal DNS path fails closed for an unknown project", func(t *testing.T) { + extra := map[string]authv1.ExtraValue{ParentTypeExtraKey: {"Project"}, ParentNameExtraKey: {"missing-project"}} + resp := sendAdmissionReview(ctx, t, httpClient, endpoint(internalDNSPath), registrationReview(privateZone.UID, extra)) + if resp.Allowed { + t.Fatal("internal DNS webhook checked an unknown project's write against the local cluster") + } + }) + cancel() select { case err := <-runErr: @@ -237,6 +298,17 @@ func postAdmissionReview( } review.Request.OldObject = runtime.RawExtension{Raw: oldRaw} } + return sendAdmissionReview(ctx, t, httpClient, url, review) +} + +func sendAdmissionReview( + ctx context.Context, + t *testing.T, + httpClient *http.Client, + url string, + review admissionv1.AdmissionReview, +) *admissionv1.AdmissionResponse { + t.Helper() body, err := json.Marshal(review) if err != nil {