From 0b1f20a109a47c868019cbfb75d0cc72229a91e9 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 9 Oct 2026 10:05:31 -0500 Subject: [PATCH 01/25] feat: Add internal DNS network contracts --- api/v1alpha/internaldns_types.go | 117 +++++++++++++ api/v1alpha/network_types.go | 4 + api/v1alpha/networkcontext_types.go | 12 ++ api/v1alpha/networkinterface_types.go | 11 ++ api/v1alpha/zz_generated.deepcopy.go | 92 ++++++++++ .../components/cell-controllers/config.yaml | 14 ++ ...working.datumapis.com_networkcontexts.yaml | 162 ++++++++++++++++++ ...rking.datumapis.com_networkinterfaces.yaml | 89 ++++++++++ .../networking.datumapis.com_networks.yaml | 12 ++ config/manager/config.yaml | 14 ++ internal/agent/catalog_test.go | 1 + internal/config/cell.go | 7 + internal/config/config.go | 63 +++++++ internal/config/zz_generated.deepcopy.go | 25 +++ internal/config/zz_generated.defaults.go | 2 + 15 files changed, 625 insertions(+) create mode 100644 api/v1alpha/internaldns_types.go diff --git a/api/v1alpha/internaldns_types.go b/api/v1alpha/internaldns_types.go new file mode 100644 index 00000000..6c2d474d --- /dev/null +++ b/api/v1alpha/internaldns_types.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// +kubebuilder:validation:Enum=Managed;Disabled +type NetworkDNSMode string + +const ( + NetworkDNSModeManaged NetworkDNSMode = "Managed" + NetworkDNSModeDisabled NetworkDNSMode = "Disabled" +) + +// NetworkDNSPolicy selects managed resolver access for a network. +type NetworkDNSPolicy struct { + // Mode controls whether network services maintain private resolver access. + // An empty value uses Managed when the Internal DNS feature is enabled. + // + // +kubebuilder:validation:Optional + Mode NetworkDNSMode `json:"mode,omitempty"` +} + +// NetworkDNSObjectReference pins a DNS resource to its API-assigned lifetime. +// +kubebuilder:validation:XValidation:rule="self.uid.size() > 0",message="uid must not be empty" +type NetworkDNSObjectReference struct { + // Name is the resource name in the network's namespace. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // UID prevents a resource recreated under the same name from inheriting + // resolver access. + // + // +kubebuilder:validation:Required + UID types.UID `json:"uid"` +} + +// NetworkDNSSettings are verified resolver settings inherited by interfaces. +type NetworkDNSSettings struct { + // ContextRef identifies the DNS serving and namespace lifetime. + // + // +kubebuilder:validation:Required + ContextRef NetworkDNSObjectReference `json:"contextRef"` + + // AccessBindingRef identifies the regional DNS authorization lifetime. + // + // +kubebuilder:validation:Required + AccessBindingRef NetworkDNSObjectReference `json:"accessBindingRef"` + + // WriterEpoch fences a previous access writer after ownership changes. + // + // +kubebuilder:validation:Minimum=1 + WriterEpoch int64 `json:"writerEpoch"` + + // Sequence is the exact DNS authorization revision these settings carry. + // + // +kubebuilder:validation:Minimum=1 + Sequence int64 `json:"sequence"` + + // ValidUntil is the earliest DNS or network authorization deadline. + ValidUntil metav1.Time `json:"validUntil"` + + // Nameservers are resolver addresses reachable from this network. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=3 + // +kubebuilder:validation:XValidation:rule="self.all(n, isIP(n))",message="nameservers must contain IP addresses" + Nameservers []string `json:"nameservers"` + + // Searches contains DNS search suffixes allocated to this network. + // + // +kubebuilder:validation:MaxItems=6 + Searches []string `json:"searches,omitempty"` +} + +// NetworkDNSAccessStatus reports the private path verified for one context. +type NetworkDNSAccessStatus struct { + // ContextRef identifies the DNS context used for the proof. + ContextRef NetworkDNSObjectReference `json:"contextRef"` + + // AccessBindingRef identifies the DNS authorization used for the proof. + AccessBindingRef NetworkDNSObjectReference `json:"accessBindingRef"` + + // WriterEpoch is the DNS authorization writer fenced by the proof. + WriterEpoch int64 `json:"writerEpoch"` + + // Sequence is the exact ready DNS authorization revision. + Sequence int64 `json:"sequence"` + + // ValidUntil is the earliest verified network-programming deadline. + ValidUntil metav1.Time `json:"validUntil"` + + // ServiceDestination identifies this DNS context inside the shared service. + // + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="serviceDestination must be an IP address" + ServiceDestination string `json:"serviceDestination"` +} + +const ( + NetworkContextDNSReady = "DNSReady" + + NetworkContextDNSReadyReasonReady = "Ready" + NetworkContextDNSReadyReasonDisabled = "Disabled" + NetworkContextDNSReadyReasonPending = "Pending" + NetworkContextDNSReadyReasonAuthorizationExpired = "AuthorizationExpired" + NetworkContextDNSReadyReasonNetworkPathNotReady = "NetworkPathNotReady" + NetworkContextDNSReadyReasonNetworkIdentityAbsent = "NetworkIdentityAbsent" + + NetworkInterfaceDNSConfiguredReasonApplied = "Applied" + NetworkInterfaceDNSConfiguredReasonExpired = "Expired" + NetworkInterfaceDNSConfiguredReasonUnsupportedMultipleContexts = "UnsupportedMultipleContexts" +) diff --git a/api/v1alpha/network_types.go b/api/v1alpha/network_types.go index 8dc07ecf..b2a5b310 100644 --- a/api/v1alpha/network_types.go +++ b/api/v1alpha/network_types.go @@ -16,6 +16,10 @@ const ( // NetworkSpec defines the desired state of a Network type NetworkSpec struct { + // DNS configures private resolver access for the network. + // + // +kubebuilder:validation:Optional + DNS NetworkDNSPolicy `json:"dns,omitempty"` // IPAM settings for the network. // diff --git a/api/v1alpha/networkcontext_types.go b/api/v1alpha/networkcontext_types.go index 7f5d7fc8..ace98918 100644 --- a/api/v1alpha/networkcontext_types.go +++ b/api/v1alpha/networkcontext_types.go @@ -43,6 +43,12 @@ type NetworkContextSpec struct { // // +kubebuilder:validation:Optional NetworkGeneration int64 `json:"networkGeneration,omitempty"` + + // DNS contains resolver settings whose DNS authorization and private path + // have both been verified for this location. + // + // +kubebuilder:validation:Optional + DNS *NetworkDNSSettings `json:"dns,omitempty"` } // NetworkContextStatus defines the observed state of NetworkContext @@ -55,6 +61,12 @@ type NetworkContextStatus struct { // // +kubebuilder:validation:Optional IPAM *NetworkContextIPAMStatus `json:"ipam,omitempty"` + + // DNSAccess identifies the current private path proof. DNSReady=True applies + // only to this exact context, binding revision, and deadline. + // + // +kubebuilder:validation:Optional + DNSAccess *NetworkDNSAccessStatus `json:"dnsAccess,omitempty"` } // NetworkContextIPAMStatus reports what IPAM holds for a network in one diff --git a/api/v1alpha/networkinterface_types.go b/api/v1alpha/networkinterface_types.go index 14552ffa..544d697d 100644 --- a/api/v1alpha/networkinterface_types.go +++ b/api/v1alpha/networkinterface_types.go @@ -113,6 +113,10 @@ const ( // interface at all. A phase of Available means no claim holds it, which is // the opposite of anything being available to serve. NetworkInterfaceHolderAvailable = "HolderAvailable" + + // NetworkInterfaceDNSConfigured is reported by the provider after applying + // the current unexpired settings in spec.dns to the workload. + NetworkInterfaceDNSConfigured = "DNSConfigured" ) const ( @@ -336,6 +340,13 @@ type NetworkInterfaceSpec struct { // +kubebuilder:validation:Optional // +kubebuilder:default="Delete" ReclaimPolicy NetworkInterfaceReclaimPolicy `json:"reclaimPolicy,omitempty"` + + // dns contains verified resolver settings inherited from the network + // context. Providers remove expired settings and report DNSConfigured after + // applying the current revision. + // + // +kubebuilder:validation:Optional + DNS *NetworkDNSSettings `json:"dns,omitempty"` } // NetworkInterfaceStatus defines the observed state of NetworkInterface: which diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index 89b48e83..48b84b4a 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -1263,6 +1263,11 @@ func (in *NetworkContextSpec) DeepCopyInto(out *NetworkContextSpec) { *out = make([]IPFamily, len(*in)) copy(*out, *in) } + if in.DNS != nil { + in, out := &in.DNS, &out.DNS + *out = new(NetworkDNSSettings) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkContextSpec. @@ -1290,6 +1295,11 @@ func (in *NetworkContextStatus) DeepCopyInto(out *NetworkContextStatus) { *out = new(NetworkContextIPAMStatus) (*in).DeepCopyInto(*out) } + if in.DNSAccess != nil { + in, out := &in.DNSAccess, &out.DNSAccess + *out = new(NetworkDNSAccessStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkContextStatus. @@ -1302,6 +1312,82 @@ func (in *NetworkContextStatus) DeepCopy() *NetworkContextStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkDNSAccessStatus) DeepCopyInto(out *NetworkDNSAccessStatus) { + *out = *in + out.ContextRef = in.ContextRef + out.AccessBindingRef = in.AccessBindingRef + in.ValidUntil.DeepCopyInto(&out.ValidUntil) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSAccessStatus. +func (in *NetworkDNSAccessStatus) DeepCopy() *NetworkDNSAccessStatus { + if in == nil { + return nil + } + out := new(NetworkDNSAccessStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkDNSObjectReference) DeepCopyInto(out *NetworkDNSObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSObjectReference. +func (in *NetworkDNSObjectReference) DeepCopy() *NetworkDNSObjectReference { + if in == nil { + return nil + } + out := new(NetworkDNSObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkDNSPolicy) DeepCopyInto(out *NetworkDNSPolicy) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSPolicy. +func (in *NetworkDNSPolicy) DeepCopy() *NetworkDNSPolicy { + if in == nil { + return nil + } + out := new(NetworkDNSPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkDNSSettings) DeepCopyInto(out *NetworkDNSSettings) { + *out = *in + out.ContextRef = in.ContextRef + out.AccessBindingRef = in.AccessBindingRef + in.ValidUntil.DeepCopyInto(&out.ValidUntil) + if in.Nameservers != nil { + in, out := &in.Nameservers, &out.Nameservers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Searches != nil { + in, out := &in.Searches, &out.Searches + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSSettings. +func (in *NetworkDNSSettings) DeepCopy() *NetworkDNSSettings { + if in == nil { + return nil + } + out := new(NetworkDNSSettings) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkIPAM) DeepCopyInto(out *NetworkIPAM) { *out = *in @@ -1639,6 +1725,11 @@ func (in *NetworkInterfaceSpec) DeepCopyInto(out *NetworkInterfaceSpec) { *out = make([]NetworkInterfaceExternalAddress, len(*in)) copy(*out, *in) } + if in.DNS != nil { + in, out := &in.DNS, &out.DNS + *out = new(NetworkDNSSettings) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceSpec. @@ -2116,6 +2207,7 @@ func (in *NetworkServiceTrafficDistribution) DeepCopy() *NetworkServiceTrafficDi // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkSpec) DeepCopyInto(out *NetworkSpec) { *out = *in + out.DNS = in.DNS in.IPAM.DeepCopyInto(&out.IPAM) if in.IPFamilies != nil { in, out := &in.IPFamilies, &out.IPFamilies diff --git a/config/components/cell-controllers/config.yaml b/config/components/cell-controllers/config.yaml index 057a1b67..fc86c4d7 100644 --- a/config/components/cell-controllers/config.yaml +++ b/config/components/cell-controllers/config.yaml @@ -1,5 +1,19 @@ apiVersion: apiserver.config.datumapis.com/v1alpha1 kind: CellControllerManager +# Internal DNS is an alpha network integration and remains off until the +# shared resolver attachments are configured for this cell. +internalDNS: + # Enables private resolver route programming and readiness reporting. + enabled: false + # Presents the same resolver address inside each consumer VPC. + # frontend: fd53::53 + # Allocates one stable service destination from this prefix per DNS context. + # servicePrefix: fd70:100::/64 + # Bounds each DNS authorization; Galactic accepts at most two minutes. + # accessLeaseDuration: 2m + # Selects the shared resolver attachments that serve authorized VPCs. + # producerAttachmentSelector: + # service.datum.net/internal-dns: "true" metricsServer: bindAddress: ":8443" ipam: diff --git a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml index 5c939214..c9c9731b 100644 --- a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml +++ b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml @@ -52,6 +52,94 @@ spec: spec: description: NetworkContextSpec defines the desired state of NetworkContext properties: + dns: + description: |- + DNS contains resolver settings whose DNS authorization and private path + have both been verified for this location. + properties: + accessBindingRef: + description: AccessBindingRef identifies the regional DNS authorization + lifetime. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + contextRef: + description: ContextRef identifies the DNS serving and namespace + lifetime. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + nameservers: + description: Nameservers are resolver addresses reachable from + this network. + items: + type: string + maxItems: 3 + minItems: 1 + type: array + x-kubernetes-validations: + - message: nameservers must contain IP addresses + rule: self.all(n, isIP(n)) + searches: + description: Searches contains DNS search suffixes allocated to + this network. + items: + type: string + maxItems: 6 + type: array + sequence: + description: Sequence is the exact DNS authorization revision + these settings carry. + format: int64 + minimum: 1 + type: integer + validUntil: + description: ValidUntil is the earliest DNS or network authorization + deadline. + format: date-time + type: string + writerEpoch: + description: WriterEpoch fences a previous access writer after + ownership changes. + format: int64 + minimum: 1 + type: integer + required: + - accessBindingRef + - contextRef + - nameservers + - sequence + - validUntil + - writerEpoch + type: object ipFamilies: description: |- IP families the network carries, projected from the Network. @@ -170,6 +258,80 @@ spec: - type type: object type: array + dnsAccess: + description: |- + DNSAccess identifies the current private path proof. DNSReady=True applies + only to this exact context, binding revision, and deadline. + properties: + accessBindingRef: + description: AccessBindingRef identifies the DNS authorization + used for the proof. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + contextRef: + description: ContextRef identifies the DNS context used for the + proof. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + sequence: + description: Sequence is the exact ready DNS authorization revision. + format: int64 + type: integer + serviceDestination: + description: ServiceDestination identifies this DNS context inside + the shared service. + type: string + x-kubernetes-validations: + - message: serviceDestination must be an IP address + rule: isIP(self) + validUntil: + description: ValidUntil is the earliest verified network-programming + deadline. + format: date-time + type: string + writerEpoch: + description: WriterEpoch is the DNS authorization writer fenced + by the proof. + format: int64 + type: integer + required: + - accessBindingRef + - contextRef + - sequence + - serviceDestination + - validUntil + - writerEpoch + type: object ipam: description: |- IPAM reports the address space IPAM holds for this network in this diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml index 6d91187a..3d84a30a 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml @@ -174,6 +174,95 @@ spec: required: - name type: object + dns: + description: |- + dns contains verified resolver settings inherited from the network + context. Providers remove expired settings and report DNSConfigured after + applying the current revision. + properties: + accessBindingRef: + description: AccessBindingRef identifies the regional DNS authorization + lifetime. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + contextRef: + description: ContextRef identifies the DNS serving and namespace + lifetime. + properties: + name: + description: Name is the resource name in the network's namespace. + minLength: 1 + type: string + uid: + description: |- + UID prevents a resource recreated under the same name from inheriting + resolver access. + type: string + required: + - name + - uid + type: object + x-kubernetes-validations: + - message: uid must not be empty + rule: self.uid.size() > 0 + nameservers: + description: Nameservers are resolver addresses reachable from + this network. + items: + type: string + maxItems: 3 + minItems: 1 + type: array + x-kubernetes-validations: + - message: nameservers must contain IP addresses + rule: self.all(n, isIP(n)) + searches: + description: Searches contains DNS search suffixes allocated to + this network. + items: + type: string + maxItems: 6 + type: array + sequence: + description: Sequence is the exact DNS authorization revision + these settings carry. + format: int64 + minimum: 1 + type: integer + validUntil: + description: ValidUntil is the earliest DNS or network authorization + deadline. + format: date-time + type: string + writerEpoch: + description: WriterEpoch fences a previous access writer after + ownership changes. + format: int64 + minimum: 1 + type: integer + required: + - accessBindingRef + - contextRef + - nameservers + - sequence + - validUntil + - writerEpoch + type: object externalAddresses: description: |- externalAddresses are the addresses the interface is reachable at from diff --git a/config/crd/bases/networking.datumapis.com_networks.yaml b/config/crd/bases/networking.datumapis.com_networks.yaml index fe83e191..712d104f 100644 --- a/config/crd/bases/networking.datumapis.com_networks.yaml +++ b/config/crd/bases/networking.datumapis.com_networks.yaml @@ -64,6 +64,18 @@ spec: spec: description: NetworkSpec defines the desired state of a Network properties: + dns: + description: DNS configures private resolver access for the network. + properties: + mode: + description: |- + Mode controls whether network services maintain private resolver access. + An empty value uses Managed when the Internal DNS feature is enabled. + enum: + - Managed + - Disabled + type: string + type: object ipFamilies: default: - IPv6 diff --git a/config/manager/config.yaml b/config/manager/config.yaml index 517fbc78..faadb1cd 100644 --- a/config/manager/config.yaml +++ b/config/manager/config.yaml @@ -1,5 +1,19 @@ apiVersion: apiserver.config.datumapis.com/v1alpha1 kind: NetworkServicesOperator +# Internal DNS is an alpha network integration and remains off until every +# serving cell has matching service attachment configuration. +internalDNS: + # Enables DNS context and regional access lifecycle management. + enabled: false + # Presents the same resolver address inside each consumer VPC. + # frontend: fd53::53 + # Allocates one stable service destination from this prefix per DNS context. + # servicePrefix: fd70:100::/64 + # Bounds each DNS authorization; Galactic accepts at most two minutes. + # accessLeaseDuration: 2m + # Selects the shared resolver attachments that serve authorized VPCs. + # producerAttachmentSelector: + # service.datum.net/internal-dns: "true" gateway: targetDomain: example.com # enableDNSIntegration controls automatic DNSRecordSet creation for Gateway diff --git a/internal/agent/catalog_test.go b/internal/agent/catalog_test.go index 39505281..09541e37 100644 --- a/internal/agent/catalog_test.go +++ b/internal/agent/catalog_test.go @@ -34,6 +34,7 @@ var inScopeAPIFiles = []string{ var outOfScopeAPIFiles = map[string]string{ "edgereachability_types.go": "hub-side record of which addresses an edge should reach; no customer-facing status", "groupversion_info.go": "scheme registration, no reasons", + "internaldns_types.go": "private resolver access is a network surface, not load balancer status", "locationbinding_types.go": "placement plumbing behind a location, never named by a load balancer", "network_types.go": "the network a service sits on; its own product surface", "networkbinding_types.go": "network attachment plumbing", diff --git a/internal/config/cell.go b/internal/config/cell.go index bf34e5af..1b910ee1 100644 --- a/internal/config/cell.go +++ b/internal/config/cell.go @@ -58,6 +58,10 @@ type CellControllerManager struct { // This field is temporary. It goes away once no cell reports Configured for // nso_cell_location_identity_source. Location LocationConfig `json:"location,omitempty"` + + // InternalDNS configures managed resolver access. It is disabled unless + // explicitly enabled. + InternalDNS InternalDNSConfig `json:"internalDNS,omitempty"` } // Validate reports whether the cell configuration can serve a cell. @@ -68,5 +72,8 @@ func (c *CellControllerManager) Validate() error { if err := c.Federation.validate(); err != nil { return fmt.Errorf("federation: %w", err) } + if err := c.InternalDNS.validate(); err != nil { + return fmt.Errorf("internalDNS: %w", err) + } return nil } diff --git a/internal/config/config.go b/internal/config/config.go index f7deaf35..098c93c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/netip" "os" "path/filepath" "slices" @@ -107,6 +108,10 @@ type NetworkServicesOperator struct { // NetworkPresence configures how a network's presence in a location is // maintained. NetworkPresence NetworkPresenceConfig `json:"networkPresence,omitempty"` + + // InternalDNS configures managed resolver access. It is disabled unless + // explicitly enabled. + InternalDNS InternalDNSConfig `json:"internalDNS,omitempty"` } // +k8s:deepcopy-gen=true @@ -147,6 +152,61 @@ func (c *NetworkPresenceConfig) validate() error { // +k8s:deepcopy-gen=true +// InternalDNSConfig configures managed resolver access and private service +// programming. +type InternalDNSConfig struct { + // Enabled activates the alpha Internal DNS integration. + Enabled bool `json:"enabled,omitempty"` + + // Frontend is the resolver address presented inside every consumer VPC. + Frontend string `json:"frontend,omitempty"` + + // ServicePrefix allocates a stable service-side destination per DNS context. + ServicePrefix string `json:"servicePrefix,omitempty"` + + // AccessLeaseDuration bounds each DNS access authorization. + AccessLeaseDuration metav1.Duration `json:"accessLeaseDuration,omitempty"` + + // ProducerAttachmentSelector selects shared DNS service attachments. + ProducerAttachmentSelector map[string]string `json:"producerAttachmentSelector,omitempty"` +} + +func SetDefaults_InternalDNSConfig(obj *InternalDNSConfig) { + if obj.Frontend == "" { + obj.Frontend = "fd53::53" + } + if obj.ServicePrefix == "" { + obj.ServicePrefix = "fd70:100::/64" + } + if obj.AccessLeaseDuration.Duration == 0 { + obj.AccessLeaseDuration = metav1.Duration{Duration: 2 * time.Minute} + } +} + +func (c *InternalDNSConfig) validate() error { + if !c.Enabled { + return nil + } + frontend, err := netip.ParseAddr(c.Frontend) + if err != nil || !frontend.Is6() { + return fmt.Errorf("frontend must be an IPv6 address, got %q", c.Frontend) + } + prefix, err := netip.ParsePrefix(c.ServicePrefix) + if err != nil || !prefix.Addr().Is6() || prefix.Bits() > 64 { + return fmt.Errorf("servicePrefix must be an IPv6 prefix of /64 or larger, got %q", c.ServicePrefix) + } + if c.AccessLeaseDuration.Duration <= 0 { + return errors.New("accessLeaseDuration must be positive") + } + if c.AccessLeaseDuration.Duration > 2*time.Minute { + return errors.New("accessLeaseDuration must not exceed Galactic's two minute authorization limit") + } + if len(c.ProducerAttachmentSelector) == 0 { + return errors.New("producerAttachmentSelector is required") + } + return nil +} + // +k8s:deepcopy-gen=true // IPAMConfig describes how the operator reaches the IPAM API server. One @@ -1559,6 +1619,9 @@ func (c *NetworkServicesOperator) Validate() error { if err := c.NetworkPresence.validate(); err != nil { return fmt.Errorf("networkPresence: %w", err) } + if err := c.InternalDNS.validate(); err != nil { + return fmt.Errorf("internalDNS: %w", err) + } return nil } diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go index 1202dfcd..f8231c2f 100644 --- a/internal/config/zz_generated.deepcopy.go +++ b/internal/config/zz_generated.deepcopy.go @@ -42,6 +42,7 @@ func (in *CellControllerManager) DeepCopyInto(out *CellControllerManager) { out.IPAM = in.IPAM out.Federation = in.Federation out.Location = in.Location + in.InternalDNS.DeepCopyInto(&out.InternalDNS) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CellControllerManager. @@ -548,6 +549,29 @@ func (in *IPAMConfig) DeepCopy() *IPAMConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InternalDNSConfig) DeepCopyInto(out *InternalDNSConfig) { + *out = *in + out.AccessLeaseDuration = in.AccessLeaseDuration + if in.ProducerAttachmentSelector != nil { + in, out := &in.ProducerAttachmentSelector, &out.ProducerAttachmentSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InternalDNSConfig. +func (in *InternalDNSConfig) DeepCopy() *InternalDNSConfig { + if in == nil { + return nil + } + out := new(InternalDNSConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IrohConnectorConfig) DeepCopyInto(out *IrohConnectorConfig) { *out = *in @@ -686,6 +710,7 @@ func (in *NetworkServicesOperator) DeepCopyInto(out *NetworkServicesOperator) { out.IPAM = in.IPAM out.NetworkInterface = in.NetworkInterface out.NetworkPresence = in.NetworkPresence + in.InternalDNS.DeepCopyInto(&out.InternalDNS) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkServicesOperator. diff --git a/internal/config/zz_generated.defaults.go b/internal/config/zz_generated.defaults.go index bf274d86..ea173ac6 100644 --- a/internal/config/zz_generated.defaults.go +++ b/internal/config/zz_generated.defaults.go @@ -53,6 +53,7 @@ func SetObjectDefaults_CellControllerManager(in *CellControllerManager) { if in.Federation.Client.Burst == 0 { in.Federation.Client.Burst = 100 } + SetDefaults_InternalDNSConfig(&in.InternalDNS) } func SetObjectDefaults_NetworkServicesOperator(in *NetworkServicesOperator) { @@ -427,4 +428,5 @@ func SetObjectDefaults_NetworkServicesOperator(in *NetworkServicesOperator) { in.IPAM.Client.Burst = 100 } SetDefaults_NetworkPresenceConfig(&in.NetworkPresence) + SetDefaults_InternalDNSConfig(&in.InternalDNS) } From 2d564057f8e43313bb2b312e6d9524212ef3a695 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 9 Oct 2026 10:05:50 -0500 Subject: [PATCH 02/25] feat: Reconcile internal DNS VPC access --- config/rbac/role.yaml | 39 + go.mod | 34 +- go.sum | 60 +- internal/cmd/cell/cell.go | 16 +- internal/cmd/manager/manager.go | 34 + internal/cmd/manager/manager_test.go | 3 + .../controller/internaldns_cell_controller.go | 405 ++++++++++ internal/controller/internaldns_controller.go | 759 ++++++++++++++++++ .../controller/networkinterface_projection.go | 1 + .../networkinterfaceclaim_controller.go | 5 + internal/dnsaccess/programming.go | 244 ++++++ 11 files changed, 1555 insertions(+), 45 deletions(-) create mode 100644 internal/controller/internaldns_cell_controller.go create mode 100644 internal/controller/internaldns_controller.go create mode 100644 internal/dnsaccess/programming.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f7e99f1c..5bfbae10 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -79,6 +79,24 @@ rules: - tlscertificates/status verbs: - get +- apiGroups: + - cloud.datumapis.com + resources: + - vpcattachments + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - cloud.datumapis.com + resources: + - vpcs + verbs: + - get + - list + - watch - apiGroups: - coordination.k8s.io resources: @@ -121,6 +139,8 @@ rules: - dns.networking.miloapis.com resources: - dnsrecordsets + - dnsresolveraccessbindings + - dnsresolvercontexts verbs: - create - delete @@ -254,6 +274,25 @@ rules: - get - list - watch +- apiGroups: + - network.datumapis.com + resources: + - serviceendpoints + - serviceroutepolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - network.datumapis.com + resources: + - serviceroutepolicies/status + verbs: + - get - apiGroups: - networking.datumapis.com resources: diff --git a/go.mod b/go.mod index 9d57c66d..9a6f17f6 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/alicebob/miniredis/v2 v2.35.0 github.com/cert-manager/cert-manager v1.17.2 github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/domainr/whois v0.1.0 github.com/envoyproxy/gateway v1.8.1 github.com/envoyproxy/gateway/test v0.0.0-20260617000843-7e4492d9a99e @@ -25,25 +24,27 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c go.datum.net/datumctl v0.18.1 + go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789 go.miloapis.com/activity v0.7.1 go.miloapis.com/dns-operator v0.5.1 - go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c + go.miloapis.com/locations v0.0.1 go.miloapis.com/milo v0.31.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/net v0.56.0 + golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af - k8s.io/api v0.36.1 + k8s.io/api v0.36.3 k8s.io/apiextensions-apiserver v0.36.1 - k8s.io/apimachinery v0.36.1 - k8s.io/client-go v0.36.1 - k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 + k8s.io/apimachinery v0.36.3 + k8s.io/client-go v0.36.3 + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/gateway-api v1.5.1 sigs.k8s.io/gateway-api/conformance v1.5.1 @@ -53,8 +54,9 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect @@ -159,7 +161,7 @@ require ( github.com/go-openapi/loads v0.23.3 // indirect github.com/go-openapi/spec v0.22.5 // indirect github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag v0.26.0 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect github.com/go-openapi/swag/fileutils v0.26.0 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect @@ -243,7 +245,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect @@ -294,16 +296,16 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto/x509roots/fallback v0.0.0-20250406160420-959f8f3db0fb // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -319,7 +321,7 @@ require ( k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect k8s.io/kubectl v0.36.1 // indirect k8s.io/metrics v0.36.1 // indirect - k8s.io/streaming v0.36.1 // indirect + k8s.io/streaming v0.36.3 // indirect oras.land/oras-go/v2 v2.6.0 // indirect periph.io/x/host/v3 v3.8.5 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect @@ -329,7 +331,7 @@ require ( sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect sigs.k8s.io/mcs-api v0.5.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect ) replace sigs.k8s.io/multicluster-runtime => github.com/datum-cloud/multicluster-runtime v0.24.2-0.20261008054817-748176e5c835 diff --git a/go.sum b/go.sum index 57e0211c..8396c753 100644 --- a/go.sum +++ b/go.sum @@ -270,10 +270,10 @@ github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdl github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= @@ -288,8 +288,8 @@ github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaM github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= @@ -682,8 +682,12 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zonedb/zonedb v1.0.3544 h1:u5a3xOaI338FclecJ/H6J7fiImVEZ/qZomJnVYIlTeM= github.com/zonedb/zonedb v1.0.3544/go.mod h1:h9mfHV/S6lboOkltULrbNY52cd7JZo6MbxIiqKMWPLg= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c h1:k0P5980+kS90zCa0M4MohyS0woJL3bh/uEarRrW9PmY= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c/go.mod h1:HTeaepXL0EqiqNsQ13K4/kGEwv+VLJ//BGrUfVIFfEA= go.datum.net/datumctl v0.18.1 h1:MP83cRkJ94aq4lYyOm5dSLU60JNnmR486LIeZnaCw8s= go.datum.net/datumctl v0.18.1/go.mod h1:0L0BBgFfud8ZuV9sbsSmeEBUPoK2q9g4boU5yczF92o= +go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789 h1:dPm2P7Ao4yRhuZe67WWXHohEo6IJZgT8lavJRn6hE4Y= +go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= @@ -704,8 +708,8 @@ go.miloapis.com/dns-operator v0.5.1 h1:3HmturI/Opl+l4/KurN6B0/XEeVefoSvYU0ewcZ8B go.miloapis.com/dns-operator v0.5.1/go.mod h1:yQSBOx1ZhSzZgg5RyNKyNHfZgjXRKhxkcGD+jvbebQE= go.miloapis.com/ipam v0.4.0 h1:U+mg3RMFXj0c2eQ0Lm15CI0bYBPFDqy8IsGLN8v/eGs= go.miloapis.com/ipam v0.4.0/go.mod h1:Jj7xg4lJi9psE0+4PuOg/GQOG8rG13h112xYoM994rc= -go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c h1:+BQirT3wYCgv7H2lEZtAH+dpMiWu9j/Wa+c8s4jJUIA= -go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c/go.mod h1:gzfAfHhSMwl/N68k/uSNYXOKK3IOBJCdXYaBgCE3gdE= +go.miloapis.com/locations v0.0.1 h1:voJKqBzyLX5x96M3+5y/ga7fgq9/+vypTizd+bBvqUY= +go.miloapis.com/locations v0.0.1/go.mod h1:gzfAfHhSMwl/N68k/uSNYXOKK3IOBJCdXYaBgCE3gdE= go.miloapis.com/milo v0.31.0 h1:gBZ9NaplTCFhgWUZJ18N38JsFDCVtdKmypwJvffSdUU= go.miloapis.com/milo v0.31.0/go.mod h1:GKK3afjCwshfZfvhjNe1wp/H45z4m7x5oG/8xbSgU1M= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= @@ -776,8 +780,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto/x509roots/fallback v0.0.0-20250406160420-959f8f3db0fb h1:Iu0p/klM0SM7atONioa/bPhLS7cjhnip99x1OIGibwg= golang.org/x/crypto/x509roots/fallback v0.0.0-20250406160420-959f8f3db0fb/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= @@ -797,8 +801,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -839,8 +843,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -848,8 +852,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -894,18 +898,18 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= helm.sh/helm/v3 v3.21.0 h1:9TRbaXQH+BIKLLDYlu++JsyWodS5kBBOLF7C7HY5+cs= helm.sh/helm/v3 v3.21.0/go.mod h1:5IvU6Ae6ruB/vasVHhnC1IU5RvqFM349vLYS1BiHqeY= -k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= -k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= +k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= -k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= -k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= +k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= k8s.io/apiserver v0.36.1 h1:iMS5V+rPUertv5P9RaqJgmHHTuh4quWpoxchvMUY+JY= k8s.io/apiserver v0.36.1/go.mod h1:Cby1PbLWztu0GDOxoO6iFOyyqIsziHNEW+w9zVQ22Kw= k8s.io/cli-runtime v0.36.1 h1:yuC/BGnnj1YYPh6D1P+pZnzinCs6DvMq86yAeNqoqzM= k8s.io/cli-runtime v0.36.1/go.mod h1:ZQWHGt8xAF7KnviB79vX0lYNyUUqKIpU+LQg7exuFAw= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= +k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/component-base v0.36.1 h1:iG6GsELftXqTNG9HG6kiVjatSgAw1sf5pJ6R5a6N0kA= k8s.io/component-base v0.36.1/go.mod h1:nf9XPlntRdqO6WMeEWAA5F93Y4ICZQdeT9GeqLDB3JI= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= @@ -918,10 +922,10 @@ k8s.io/kubectl v0.36.1 h1:96HqS9twIdHM0MlJLTwbo14b9kUKPkOzZ4tlRDLv4qI= k8s.io/kubectl v0.36.1/go.mod h1:/DGPAIewKsFWF9VFgGvkPhao2Ev4SNuE3BioZo8yPbk= k8s.io/metrics v0.36.1 h1:MQPb+G4RhrKEpt8NETPssbW8QgGUc4Jbqu1jx+kPqGk= k8s.io/metrics v0.36.1/go.mod h1:xqS8XcWLjDzo6E7DJm/GfjKpRKdN5/MtJAQFuV6nLUc= -k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= -k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w= +k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= periph.io/x/host/v3 v3.8.5 h1:g4g5xE1XZtDiGl1UAJaUur1aT7uNiFLMkyMEiZ7IHII= @@ -948,7 +952,7 @@ sigs.k8s.io/mcs-api v0.5.0 h1:UMgKuFFIRVEy4LR2LQovPnBkV9rgmZMT+sJO7MkaVj4= sigs.k8s.io/mcs-api v0.5.0/go.mod h1:zZ5CK8uS6HaLkxY4HqsmcBHfzHuNMrY2uJy8T7jffK4= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cmd/cell/cell.go b/internal/cmd/cell/cell.go index ad84ea6f..b96edf45 100644 --- a/internal/cmd/cell/cell.go +++ b/internal/cmd/cell/cell.go @@ -19,6 +19,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" @@ -45,6 +47,8 @@ func init() { utilruntime.Must(locationsv1alpha1.AddToScheme(scheme)) utilruntime.Must(networkingv1alpha1.AddToScheme(scheme)) utilruntime.Must(ipamv1alpha1.AddToScheme(scheme)) + utilruntime.Must(cloudv1alpha1.AddToScheme(scheme)) + utilruntime.Must(networkv1alpha1.AddToScheme(scheme)) } // NewCommand builds the cell controller manager command. @@ -279,7 +283,7 @@ func controllerRegistrations( ipamClients controller.IPAMClientFactory, hubCluster cluster.Cluster, ) []namedSetup { - registrations := make([]namedSetup, 0, 5) + registrations := make([]namedSetup, 0, 6) registrations = append(registrations, namedSetup{"networkinterfaceclaim", func() (bool, error) { return true, (&controller.NetworkInterfaceClaimReconciler{ @@ -317,6 +321,16 @@ func controllerRegistrations( }).SetupWithManager(mgr) }}) + registrations = append(registrations, namedSetup{"internaldns-cell", func() (bool, error) { + if !serverConfig.InternalDNS.Enabled { + return false, nil + } + return true, (&controller.InternalDNSCellReconciler{ + Config: serverConfig.InternalDNS, + HubCluster: hubCluster, + }).SetupWithManager(mgr) + }}) + return registrations } diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index 0c0ea42f..291e3c77 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -405,6 +405,11 @@ func NewCommand(build BuildInfo) *cobra.Command { setupLog.Error(err, "unable to build wildcard entitlement client") os.Exit(1) } + projectIdentities, err := newProjectIdentityResolver(serverConfig) + if err != nil { + setupLog.Error(err, "unable to build project identity client") + os.Exit(1) + } registeredControllers, err := setupControllers(mgr, serverConfig, controllerDeps{ downstreamCluster: downstreamCluster, @@ -414,6 +419,7 @@ func NewCommand(build BuildInfo) *cobra.Command { certificateServiceReader: certificateServiceReader, certificateServiceRoots: certificateServiceRoots, wildcardEntitlements: wildcardEntitlements, + projectIdentities: projectIdentities, }) if err != nil { setupLog.Error(err, "unable to set up controllers") @@ -593,6 +599,22 @@ type controllerDeps struct { certificateServiceReader client.Reader certificateServiceRoots *x509.CertPool wildcardEntitlements controller.WildcardEntitlementChecker + projectIdentities controller.ProjectIdentityResolver +} + +func newProjectIdentityResolver(serverConfig config.NetworkServicesOperator) (controller.ProjectIdentityResolver, error) { + if !serverConfig.InternalDNS.Enabled { + return nil, nil + } + restConfig, err := serverConfig.Discovery.DiscoveryRestConfig() + if err != nil { + return nil, err + } + reader, err := client.New(restConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + return &controller.APIProjectIdentityResolver{Reader: reader}, nil } // newCertificateServiceReader returns an uncached client for the cluster the @@ -697,6 +719,18 @@ func controllerRegistrations( Events: presenceEvents, }).SetupWithManager(mgr, deps.singletonManager) }}, + {"internaldns-project", serverConfig.InternalDNS.Enabled, func() error { + return (&controller.InternalDNSProjectReconciler{ + Config: serverConfig.InternalDNS, + Projects: deps.projectIdentities, + }).SetupWithManager(mgr) + }}, + {"internaldns-readiness", serverConfig.InternalDNS.Enabled, func() error { + return (&controller.InternalDNSReadinessReconciler{ + Config: serverConfig.InternalDNS, + Projects: controller.NewProjectClusterResolver(mgr), + }).SetupWithManager(deps.singletonManager) + }}, // The interfaces a cell publishes arrive on the hub, and only the // multicluster manager reaches the project control planes they are for. // The projector goes on the singleton manager for the same reason the diff --git a/internal/cmd/manager/manager_test.go b/internal/cmd/manager/manager_test.go index f54a54d5..488473b4 100644 --- a/internal/cmd/manager/manager_test.go +++ b/internal/cmd/manager/manager_test.go @@ -30,6 +30,9 @@ var reconcilerControllerNames = map[string]string{ "GatewayReconciler": "gateway", "GatewayResourceReplicatorReconciler": "gateway_resource_replicator", "HTTPProxyReconciler": "httpproxy", + "InternalDNSCellReconciler": "internaldns-cell", + "InternalDNSProjectReconciler": "internaldns-project", + "InternalDNSReadinessReconciler": "internaldns-readiness", "IrohDNSReconciler": "iroh-dns", "NetworkBindingReconciler": "networkbinding", "NetworkContextReconciler": "networkcontext", diff --git a/internal/controller/internaldns_cell_controller.go b/internal/controller/internaldns_cell_controller.go new file mode 100644 index 00000000..97388bc7 --- /dev/null +++ b/internal/controller/internaldns_cell_controller.go @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "errors" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" + "go.datum.net/network-services-operator/internal/dnsaccess" +) + +const ( + internalDNSConsumerVPCLabel = "networking.datumapis.com/internal-dns-vpc-uid" + internalDNSNetworkLease = 90 * time.Second + internalDNSNetworkRenewBefore = 45 * time.Second +) + +type InternalDNSCellReconciler struct { + Config config.InternalDNSConfig + HubCluster cluster.Cluster + Now func() time.Time + + mgr mcmanager.Manager +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts;networkinterfaces,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcs,verbs=get;list;watch +// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcattachments,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=network.datumapis.com,resources=serviceendpoints;serviceroutepolicies,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=network.datumapis.com,resources=serviceroutepolicies/status,verbs=get + +func (r *InternalDNSCellReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + cl, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + networkContext := &networkingv1alpha.NetworkContext{} + if err := cl.GetClient().Get(ctx, req.NamespacedName, networkContext); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + return r.reconcile(ctx, cl.GetClient(), networkContext) +} + +func (r *InternalDNSCellReconciler) reconcile( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) (ctrl.Result, error) { + settings, err := internalDNSSettingsFromAnnotations(networkContext.Annotations, r.Config.Frontend) + serviceDestination := networkContext.Annotations[InternalDNSServiceDestinationAnnotation] + region := networkContext.Annotations[InternalDNSRegionAnnotation] + if err != nil || serviceDestination == "" || region == "" || !settings.ValidUntil.After(r.now()) || + !networkContext.DeletionTimestamp.IsZero() { + if cleanupErr := r.cleanup(ctx, cl, networkContext); cleanupErr != nil { + return ctrl.Result{}, cleanupErr + } + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.report(ctx, networkContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonAuthorizationExpired, + Message: "No current DNS access authorization is available", + }) + } + + vpc, consumerAttachments, err := r.consumerVPC(ctx, cl, networkContext) + if err != nil { + cleanupErr := r.cleanup(ctx, cl, networkContext) + if reportErr := r.report(ctx, networkContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkIdentityAbsent, + Message: err.Error(), + }); reportErr != nil { + return ctrl.Result{}, errors.Join(cleanupErr, reportErr) + } + if cleanupErr != nil { + return ctrl.Result{}, cleanupErr + } + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil + } + for _, attachment := range consumerAttachments { + if attachment.Labels[internalDNSConsumerVPCLabel] == string(vpc.UID) { + continue + } + patch := client.MergeFrom(attachment.DeepCopy()) + if attachment.Labels == nil { + attachment.Labels = map[string]string{} + } + attachment.Labels[internalDNSConsumerVPCLabel] = string(vpc.UID) + if err := cl.Patch(ctx, attachment, patch); err != nil { + return ctrl.Result{}, err + } + } + + deadline := minTime(settings.ValidUntil.Time, r.now().Add(internalDNSNetworkLease)) + var attachments cloudv1alpha1.VPCAttachmentList + if err := cl.List(ctx, &attachments); err != nil { + return ctrl.Result{}, err + } + attachmentPointers := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments.Items)) + for i := range attachments.Items { + attachmentPointers = append(attachmentPointers, &attachments.Items[i]) + } + + proofUntil := deadline + for _, protocol := range []networkv1alpha1.NetworkRuleProtocol{ + networkv1alpha1.NetworkRuleProtocolUDP, networkv1alpha1.NetworkRuleProtocolTCP, + } { + endpoint, policy, err := r.ensureServicePath( + ctx, cl, networkContext, vpc, protocol, serviceDestination, region, deadline, + ) + if err != nil { + return ctrl.Result{}, err + } + until, ready := policyProgrammed(policy, endpoint, attachmentPointers, vpc.Status.VPC, r.now()) + if !ready { + if err := r.report(ctx, networkContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkPathNotReady, + Message: "Galactic has not acknowledged the current DNS service path on every required node", + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil + } + proofUntil = minTime(proofUntil, until) + } + + access := &networkingv1alpha.NetworkDNSAccessStatus{ + ContextRef: settings.ContextRef, + AccessBindingRef: settings.AccessBindingRef, + WriterEpoch: settings.WriterEpoch, + Sequence: settings.Sequence, + ValidUntil: metav1.NewTime(proofUntil.UTC()), + ServiceDestination: serviceDestination, + } + if err := r.report(ctx, networkContext, access, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, + Message: "DNS authorization and Galactic programming are current", + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: renewalDelay(r.now(), proofUntil)}, nil +} + +func (r *InternalDNSCellReconciler) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *InternalDNSCellReconciler) consumerVPC( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) (*cloudv1alpha1.VPC, []*cloudv1alpha1.VPCAttachment, error) { + var interfaces networkingv1alpha.NetworkInterfaceList + if err := cl.List(ctx, &interfaces, client.InNamespace(networkContext.Namespace)); err != nil { + return nil, nil, err + } + attachments := []*cloudv1alpha1.VPCAttachment{} + var vpc *cloudv1alpha1.VPC + for i := range interfaces.Items { + iface := &interfaces.Items[i] + if iface.Status.NetworkContextRef == nil || iface.Status.NetworkContextRef.Name != networkContext.Name || + iface.Status.AttachmentRef == nil || iface.Status.AttachmentRef.APIGroup != "cloud.datumapis.com" || + iface.Status.AttachmentRef.Kind != "VPCAttachment" || !iface.DeletionTimestamp.IsZero() { + continue + } + attachment := &cloudv1alpha1.VPCAttachment{} + key := client.ObjectKey{Namespace: iface.Namespace, Name: iface.Status.AttachmentRef.Name} + if err := cl.Get(ctx, key, attachment); err != nil { + return nil, nil, fmt.Errorf("read VPC attachment %s: %w", key, err) + } + candidate := &cloudv1alpha1.VPC{} + vpcKey := client.ObjectKey{Namespace: attachment.Namespace, Name: attachment.Spec.VPC.Name} + if err := cl.Get(ctx, vpcKey, candidate); err != nil { + return nil, nil, fmt.Errorf("read consumer VPC %s: %w", vpcKey, err) + } + if candidate.UID == "" || candidate.Status.VPC == "" { + return nil, nil, fmt.Errorf("consumer VPC %s has no current network identity", vpcKey) + } + if vpc != nil && vpc.UID != candidate.UID { + return nil, nil, errors.New("one network context resolves to more than one consumer VPC lifetime") + } + vpc = candidate + attachments = append(attachments, attachment) + } + if vpc == nil || len(attachments) == 0 { + return nil, nil, errors.New("no realized interface identifies the consumer VPC lifetime") + } + return vpc, attachments, nil +} + +func (r *InternalDNSCellReconciler) ensureServicePath( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, + vpc *cloudv1alpha1.VPC, + protocol networkv1alpha1.NetworkRuleProtocol, + destination string, + region string, + deadline time.Time, +) (*networkv1alpha1.ServiceEndpoint, *networkv1alpha1.ServiceRoutePolicy, error) { + name := boundedDNSName(networkContext.Name+"-dns-"+string(protocol), string(networkContext.UID)+"/"+string(protocol)) + endpoint := &networkv1alpha1.ServiceEndpoint{ObjectMeta: metav1.ObjectMeta{ + Namespace: networkContext.Namespace, Name: name, + }} + _, err := controllerutil.CreateOrUpdate(ctx, cl, endpoint, func() error { + endpoint.Spec = networkv1alpha1.ServiceEndpointSpec{ + ServiceClass: "internal-dns", Address: destination, Port: 53, Protocol: protocol, + Region: region, DeliveryMode: networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal, + AttachmentSelector: &metav1.LabelSelector{MatchLabels: r.Config.ProducerAttachmentSelector}, + } + return controllerutil.SetControllerReference(networkContext, endpoint, cl.Scheme()) + }) + if err != nil { + return nil, nil, err + } + + policy := &networkv1alpha1.ServiceRoutePolicy{ObjectMeta: metav1.ObjectMeta{ + Namespace: networkContext.Namespace, Name: name, + }} + _, err = controllerutil.CreateOrUpdate(ctx, cl, policy, func() error { + activeDeadline := networkAuthorizationDeadline(policy, r.now(), deadline) + policy.Spec = networkv1alpha1.ServiceRoutePolicySpec{ + ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: endpoint.Name}, + ConsumerVPCRef: &networkv1alpha1.ServiceRouteVPCReference{Name: vpc.Name, UID: string(vpc.UID)}, + Frontend: &networkv1alpha1.ServiceRouteFrontend{Address: r.Config.Frontend}, + Authorization: &networkv1alpha1.ServiceRouteAuthorization{ValidUntil: metav1.NewTime(activeDeadline.UTC())}, + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{ + internalDNSConsumerVPCLabel: string(vpc.UID), + }}, + ProtocolPorts: []networkv1alpha1.ServiceRouteProtocolPort{{Protocol: protocol, Port: 53}}, + Region: region, + } + return controllerutil.SetControllerReference(networkContext, policy, cl.Scheme()) + }) + if err != nil { + return nil, nil, err + } + return endpoint, policy, nil +} + +func networkAuthorizationDeadline( + policy *networkv1alpha1.ServiceRoutePolicy, + now time.Time, + dnsValidUntil time.Time, +) time.Time { + if policy.Spec.Authorization != nil { + validUntil := policy.Spec.Authorization.ValidUntil.Time + if validUntil.After(now.Add(internalDNSNetworkRenewBefore)) && + !validUntil.After(dnsValidUntil) && !validUntil.After(now.Add(internalDNSNetworkLease)) { + return validUntil + } + } + return minTime(now.Add(internalDNSNetworkLease), dnsValidUntil) +} + +func policyProgrammed( + policy *networkv1alpha1.ServiceRoutePolicy, + endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, + consumerVPCIdentity string, + now time.Time, +) (time.Time, bool) { + if policy.UID == "" || endpoint.UID == "" || policy.Status.ObservedGeneration != policy.Generation || + !apimeta.IsStatusConditionTrue(policy.Status.Conditions, networkv1alpha1.ConditionTypeServiceRouteAccepted) || + policy.Spec.Authorization == nil || !policy.Spec.Authorization.ValidUntil.After(now) { + return time.Time{}, false + } + plan, err := dnsaccess.ProgrammingPlan(policy, endpoint, attachments, consumerVPCIdentity) + if err != nil { + return time.Time{}, false + } + validUntil := policy.Spec.Authorization.ValidUntil.Time + for _, node := range plan.Nodes { + matched := 0 + for _, report := range policy.Status.Nodes { + if report.NodeName != node { + continue + } + matched++ + if !report.Ready || report.PolicyUID != string(policy.UID) || + report.ObservedGeneration != policy.Generation || report.InputDigest != plan.InputDigest || + !report.ValidUntil.After(now) { + return time.Time{}, false + } + validUntil = minTime(validUntil, report.ValidUntil.Time) + } + if matched != 1 { + return time.Time{}, false + } + } + return validUntil, true +} + +func (r *InternalDNSCellReconciler) report( + ctx context.Context, + networkContext *networkingv1alpha.NetworkContext, + access *networkingv1alpha.NetworkDNSAccessStatus, + condition metav1.Condition, +) error { + hub := r.HubCluster.GetClient() + current := &networkingv1alpha.NetworkContext{} + key := client.ObjectKeyFromObject(networkContext) + if err := hub.Get(ctx, key, current); err != nil { + return client.IgnoreNotFound(err) + } + patch := client.MergeFrom(current.DeepCopy()) + current.Status.DNSAccess = access + condition.ObservedGeneration = current.Generation + apimeta.SetStatusCondition(¤t.Status.Conditions, condition) + return hub.Status().Patch(ctx, current, patch) +} + +func (r *InternalDNSCellReconciler) cleanup( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) error { + var errs []error + var interfaces networkingv1alpha.NetworkInterfaceList + if err := cl.List(ctx, &interfaces, client.InNamespace(networkContext.Namespace)); err != nil { + errs = append(errs, err) + } else { + for i := range interfaces.Items { + iface := &interfaces.Items[i] + if iface.Status.NetworkContextRef == nil || iface.Status.NetworkContextRef.Name != networkContext.Name || + iface.Status.AttachmentRef == nil || iface.Status.AttachmentRef.APIGroup != "cloud.datumapis.com" || + iface.Status.AttachmentRef.Kind != "VPCAttachment" { + continue + } + attachment := &cloudv1alpha1.VPCAttachment{} + key := client.ObjectKey{Namespace: iface.Namespace, Name: iface.Status.AttachmentRef.Name} + if err := cl.Get(ctx, key, attachment); err != nil { + if !apierrors.IsNotFound(err) { + errs = append(errs, err) + } + continue + } + if _, present := attachment.Labels[internalDNSConsumerVPCLabel]; !present { + continue + } + patch := client.MergeFrom(attachment.DeepCopy()) + delete(attachment.Labels, internalDNSConsumerVPCLabel) + if err := cl.Patch(ctx, attachment, patch); err != nil { + errs = append(errs, err) + } + } + } + for _, protocol := range []string{"udp", "tcp"} { + name := boundedDNSName(networkContext.Name+"-dns-"+protocol, string(networkContext.UID)+"/"+protocol) + policy := &networkv1alpha1.ServiceRoutePolicy{ObjectMeta: metav1.ObjectMeta{ + Namespace: networkContext.Namespace, Name: name, + }} + if err := cl.Delete(ctx, policy); err != nil && !apierrors.IsNotFound(err) { + errs = append(errs, err) + } + endpoint := &networkv1alpha1.ServiceEndpoint{ObjectMeta: metav1.ObjectMeta{ + Namespace: networkContext.Namespace, Name: name, + }} + if err := cl.Delete(ctx, endpoint); err != nil && !apierrors.IsNotFound(err) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (r *InternalDNSCellReconciler) SetupWithManager(mgr mcmanager.Manager) error { + if r.HubCluster == nil { + return errors.New("a federation hub cluster is required") + } + r.mgr = mgr + return mcbuilder.ControllerManagedBy(mgr). + For(&networkingv1alpha.NetworkContext{}, mcbuilder.WithEngageWithLocalCluster(false)). + Named("internaldns-cell"). + Complete(r) +} + +func minTime(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go new file mode 100644 index 00000000..f9be6cab --- /dev/null +++ b/internal/controller/internaldns_controller.go @@ -0,0 +1,759 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/netip" + "strconv" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/manager" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" +) + +const ( + internalDNSFinalizer = "networking.datumapis.com/internal-dns-access" + + InternalDNSContextNameAnnotation = "networking.datumapis.com/internal-dns-context-name" + InternalDNSContextUIDAnnotation = "networking.datumapis.com/internal-dns-context-uid" + InternalDNSAccessNameAnnotation = "networking.datumapis.com/internal-dns-access-name" + InternalDNSAccessUIDAnnotation = "networking.datumapis.com/internal-dns-access-uid" + InternalDNSWriterEpochAnnotation = "networking.datumapis.com/internal-dns-writer-epoch" + InternalDNSSequenceAnnotation = "networking.datumapis.com/internal-dns-sequence" + InternalDNSValidUntilAnnotation = "networking.datumapis.com/internal-dns-valid-until" + InternalDNSServiceDestinationAnnotation = "networking.datumapis.com/internal-dns-service-destination" + InternalDNSSuffixAnnotation = "networking.datumapis.com/internal-dns-suffix" + InternalDNSRegionAnnotation = "networking.datumapis.com/internal-dns-region" + + internalDNSOwnerNetworkUIDAnnotation = "networking.datumapis.com/internal-dns-network-uid" + internalDNSOwnerProjectUIDAnnotation = "networking.datumapis.com/internal-dns-project-uid" + + internalDNSRegionTopologyKey = "topology.datum.net/region" + internalDNSRenewBefore = 45 * time.Second + internalDNSRetryInterval = 10 * time.Second +) + +var ( + internalDNSContextGVK = schema.GroupVersionKind{ + Group: "dns.networking.miloapis.com", Version: "v1alpha1", Kind: "DNSResolverContext", + } + internalDNSAccessGVK = schema.GroupVersionKind{ + Group: "dns.networking.miloapis.com", Version: "v1alpha1", Kind: "DNSResolverAccessBinding", + } +) + +type ProjectIdentityResolver interface { + ProjectUID(context.Context, string) (types.UID, error) +} + +type APIProjectIdentityResolver struct { + Reader client.Reader +} + +func (r *APIProjectIdentityResolver) ProjectUID(ctx context.Context, name string) (types.UID, error) { + project := &unstructured.Unstructured{} + project.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "resourcemanager.miloapis.com", Version: "v1alpha1", Kind: "Project", + }) + if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, project); err != nil { + return "", err + } + if project.GetUID() == "" { + return "", fmt.Errorf("project %q has no UID", name) + } + return project.GetUID(), nil +} + +type InternalDNSProjectReconciler struct { + Config config.InternalDNSConfig + Projects ProjectIdentityResolver + Now func() time.Time + + mgr mcmanager.Manager +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networks,verbs=get;list;watch +// +kubebuilder:rbac:groups=locations.miloapis.com,resources=locations,verbs=get;list;watch +// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsresolvercontexts;dnsresolveraccessbindings,verbs=get;list;watch;create;update;patch;delete + +func (r *InternalDNSProjectReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + cl, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + ctx = mccontext.WithCluster(ctx, req.ClusterName) + return r.reconcile(ctx, cl.GetClient(), string(req.ClusterName), req.NamespacedName) +} + +func (r *InternalDNSProjectReconciler) reconcile( + ctx context.Context, + cl client.Client, + project string, + key client.ObjectKey, +) (ctrl.Result, error) { + networkContext := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, key, networkContext); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + region, err := r.region(ctx, cl, networkContext) + if err != nil { + return ctrl.Result{}, err + } + + if !networkContext.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(networkContext, internalDNSFinalizer) { + if err := r.cleanupRegion(ctx, cl, networkContext); err != nil { + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(networkContext, internalDNSFinalizer) + if err := cl.Update(ctx, networkContext); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + previousRegion := networkContext.Annotations[InternalDNSRegionAnnotation] + if previousRegion != "" && previousRegion != region { + if err := r.cleanupRegion(ctx, cl, networkContext); err != nil { + return ctrl.Result{}, err + } + } + if previousRegion != region { + patch := client.MergeFrom(networkContext.DeepCopy()) + if networkContext.Annotations == nil { + networkContext.Annotations = map[string]string{} + } + if region == "" { + delete(networkContext.Annotations, InternalDNSRegionAnnotation) + } else { + networkContext.Annotations[InternalDNSRegionAnnotation] = region + } + if err := cl.Patch(ctx, networkContext, patch); err != nil { + return ctrl.Result{}, err + } + } + + network := &networkingv1alpha.Network{} + if err := cl.Get(ctx, client.ObjectKey{Namespace: key.Namespace, Name: networkContext.Spec.Network.Name}, network); err != nil { + return ctrl.Result{}, err + } + if network.Spec.DNS.Mode == networkingv1alpha.NetworkDNSModeDisabled { + return ctrl.Result{}, r.disable(ctx, cl, networkContext, network) + } + if region == "" { + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + } + + if controllerutil.AddFinalizer(networkContext, internalDNSFinalizer) { + if err := cl.Update(ctx, networkContext); err != nil { + return ctrl.Result{}, err + } + } + + projectUID, err := r.Projects.ProjectUID(ctx, project) + if err != nil { + return ctrl.Result{}, fmt.Errorf("resolve project identity: %w", err) + } + consumerID := string(projectUID) + "/" + string(network.UID) + dnsContext, err := r.ensureContext(ctx, cl, network, projectUID, consumerID) + if err != nil { + return ctrl.Result{}, err + } + epoch, _, _ := unstructured.NestedInt64(dnsContext.Object, "status", "accessWriterEpoch") + suffix, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "suffix") + zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") + zoneUID, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "uid") + if dnsContext.GetUID() == "" || epoch < 1 || suffix == "" || zoneName == "" || zoneUID == "" { + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + } + + destination, err := internalDNSDestination(r.Config.ServicePrefix, dnsContext.GetUID()) + if err != nil { + return ctrl.Result{}, err + } + binding, ready, err := r.ensureAccess(ctx, cl, network, projectUID, dnsContext, region, destination, epoch) + if err != nil { + return ctrl.Result{}, err + } + if !ready { + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + } + + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + validUntil, err := objectTime(binding, "spec", "authorization", "validUntil") + if err != nil { + return ctrl.Result{}, err + } + if err := r.publishIntent(ctx, cl, networkContext, map[string]string{ + InternalDNSContextNameAnnotation: dnsContext.GetName(), + InternalDNSContextUIDAnnotation: string(dnsContext.GetUID()), + InternalDNSAccessNameAnnotation: binding.GetName(), + InternalDNSAccessUIDAnnotation: string(binding.GetUID()), + InternalDNSWriterEpochAnnotation: strconv.FormatInt(epoch, 10), + InternalDNSSequenceAnnotation: strconv.FormatInt(sequence, 10), + InternalDNSValidUntilAnnotation: validUntil.UTC().Format(time.RFC3339Nano), + InternalDNSServiceDestinationAnnotation: destination, + InternalDNSSuffixAnnotation: suffix, + InternalDNSRegionAnnotation: region, + }); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{RequeueAfter: renewalDelay(r.now(), validUntil)}, nil +} + +func (r *InternalDNSProjectReconciler) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *InternalDNSProjectReconciler) region( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) (string, error) { + location := &locationsv1alpha1.Location{} + if err := cl.Get(ctx, client.ObjectKey{Name: networkContext.Spec.Location.Name}, location); err != nil { + return "", client.IgnoreNotFound(err) + } + return location.Spec.Topology[internalDNSRegionTopologyKey], nil +} + +func (r *InternalDNSProjectReconciler) ensureContext( + ctx context.Context, + cl client.Client, + network *networkingv1alpha.Network, + projectUID types.UID, + consumerID string, +) (*unstructured.Unstructured, error) { + name := internalDNSResourceName(network.Name, network.UID) + current := newInternalDNSObject(internalDNSContextGVK, network.Namespace, name) + err := cl.Get(ctx, client.ObjectKeyFromObject(current), current) + if err != nil && !apierrors.IsNotFound(err) { + return nil, err + } + if err == nil { + ownedNetwork := current.GetAnnotations()[internalDNSOwnerNetworkUIDAnnotation] + existingConsumer, _, _ := unstructured.NestedString(current.Object, "spec", "consumerID") + if ownedNetwork != string(network.UID) || existingConsumer != consumerID { + return nil, fmt.Errorf("DNSResolverContext %s/%s belongs to another network lifetime", network.Namespace, name) + } + return current, nil + } + + desired := newInternalDNSObject(internalDNSContextGVK, network.Namespace, name) + desired.SetAnnotations(map[string]string{ + internalDNSOwnerNetworkUIDAnnotation: string(network.UID), + internalDNSOwnerProjectUIDAnnotation: string(projectUID), + }) + desired.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: networkingv1alpha.GroupVersion.String(), Kind: "Network", Name: network.Name, + UID: network.UID, Controller: boolPtr(true), BlockOwnerDeletion: boolPtr(true), + }}) + desired.Object["spec"] = map[string]any{ + "consumerID": consumerID, + "managedNamespace": map[string]any{"enabled": true}, + } + if err := cl.Create(ctx, desired); err != nil { + return nil, err + } + return desired, nil +} + +func (r *InternalDNSProjectReconciler) ensureAccess( + ctx context.Context, + cl client.Client, + network *networkingv1alpha.Network, + projectUID types.UID, + dnsContext *unstructured.Unstructured, + region string, + destination string, + epoch int64, +) (*unstructured.Unstructured, bool, error) { + name := internalDNSRegionalName(dnsContext.GetName(), region) + current := newInternalDNSObject(internalDNSAccessGVK, network.Namespace, name) + err := cl.Get(ctx, client.ObjectKeyFromObject(current), current) + if err != nil && !apierrors.IsNotFound(err) { + return nil, false, err + } + now := r.now() + sequence := int64(1) + if err == nil { + if current.GetAnnotations()[internalDNSOwnerNetworkUIDAnnotation] != string(network.UID) { + return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s belongs to another network lifetime", network.Namespace, name) + } + if accessBindingMatches(current, dnsContext, region, destination, epoch) { + sequence, _, _ = unstructured.NestedInt64(current.Object, "spec", "authorization", "sequence") + validUntil, _ := objectTime(current, "spec", "authorization", "validUntil") + if bindingReady(current, sequence) && validUntil.After(now.Add(internalDNSRenewBefore)) { + return current, true, nil + } + if validUntil.After(now) && !bindingReady(current, sequence) { + return current, false, nil + } + sequence++ + } else { + currentEpoch, _, _ := unstructured.NestedInt64(current.Object, "spec", "authorization", "writerEpoch") + currentContextUID, _, _ := unstructured.NestedString(current.Object, "spec", "contextRef", "uid") + if currentContextUID == string(dnsContext.GetUID()) && currentEpoch > epoch { + return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s has writer epoch %d ahead of context epoch %d", + network.Namespace, name, currentEpoch, epoch) + } + } + } + + validUntil := metav1.NewTime(now.Add(r.Config.AccessLeaseDuration.Duration).UTC()) + desiredSpec := map[string]any{ + "contextRef": map[string]any{"name": dnsContext.GetName(), "uid": string(dnsContext.GetUID())}, + "region": region, + "queryIdentity": map[string]any{"type": "DestinationAddress", "value": destination}, + "port": int64(53), + "transports": []any{"UDP", "TCP"}, + "authorization": map[string]any{ + "writerEpoch": epoch, + "sequence": sequence, + "validUntil": validUntil.Format(time.RFC3339Nano), + }, + } + annotations := current.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[internalDNSOwnerNetworkUIDAnnotation] = string(network.UID) + annotations[internalDNSOwnerProjectUIDAnnotation] = string(projectUID) + annotations[InternalDNSContextUIDAnnotation] = string(dnsContext.GetUID()) + annotations[InternalDNSRegionAnnotation] = region + current.SetAnnotations(annotations) + current.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: internalDNSContextGVK.GroupVersion().String(), Kind: internalDNSContextGVK.Kind, + Name: dnsContext.GetName(), UID: dnsContext.GetUID(), Controller: boolPtr(true), BlockOwnerDeletion: boolPtr(true), + }}) + if apierrors.IsNotFound(err) { + current.Object["spec"] = desiredSpec + if err := cl.Create(ctx, current); err != nil { + return nil, false, err + } + return current, false, nil + } + if err := unstructured.SetNestedMap(current.Object, desiredSpec, "spec"); err != nil { + return nil, false, err + } + if err := cl.Update(ctx, current); err != nil { + return nil, false, err + } + return current, false, nil +} + +func accessBindingMatches( + binding *unstructured.Unstructured, + dnsContext *unstructured.Unstructured, + region string, + destination string, + epoch int64, +) bool { + contextName, _, _ := unstructured.NestedString(binding.Object, "spec", "contextRef", "name") + contextUID, _, _ := unstructured.NestedString(binding.Object, "spec", "contextRef", "uid") + boundRegion, _, _ := unstructured.NestedString(binding.Object, "spec", "region") + identityType, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "type") + identityValue, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "value") + writerEpoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "writerEpoch") + return contextName == dnsContext.GetName() && contextUID == string(dnsContext.GetUID()) && + boundRegion == region && identityType == "DestinationAddress" && identityValue == destination && writerEpoch == epoch +} + +func (r *InternalDNSProjectReconciler) disable( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, + network *networkingv1alpha.Network, +) error { + var errs []error + if err := r.cleanupRegion(ctx, cl, networkContext); err != nil { + errs = append(errs, err) + } + contextObj := newInternalDNSObject(internalDNSContextGVK, network.Namespace, internalDNSResourceName(network.Name, network.UID)) + if err := cl.Delete(ctx, contextObj); err != nil && !apierrors.IsNotFound(err) { + errs = append(errs, err) + } + if err := r.clearIntent(ctx, cl, networkContext); err != nil { + errs = append(errs, err) + } + if controllerutil.RemoveFinalizer(networkContext, internalDNSFinalizer) { + if err := cl.Update(ctx, networkContext); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (r *InternalDNSProjectReconciler) cleanupRegion( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) error { + region := networkContext.Annotations[InternalDNSRegionAnnotation] + contextName := networkContext.Annotations[InternalDNSContextNameAnnotation] + if region == "" || contextName == "" { + return nil + } + var contexts networkingv1alpha.NetworkContextList + if err := cl.List(ctx, &contexts, client.InNamespace(networkContext.Namespace), client.MatchingLabels{ + networkingv1alpha.NetworkUIDLabel: networkContext.Labels[networkingv1alpha.NetworkUIDLabel], + }); err != nil { + return err + } + for i := range contexts.Items { + other := &contexts.Items[i] + if other.UID == networkContext.UID || !other.DeletionTimestamp.IsZero() { + continue + } + otherRegion := other.Annotations[InternalDNSRegionAnnotation] + if otherRegion == "" { + var err error + otherRegion, err = r.region(ctx, cl, other) + if err != nil { + return err + } + } + if otherRegion == region { + return nil + } + } + binding := newInternalDNSObject(internalDNSAccessGVK, networkContext.Namespace, internalDNSRegionalName(contextName, region)) + if err := cl.Delete(ctx, binding); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} + +func (r *InternalDNSProjectReconciler) publishIntent( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, + values map[string]string, +) error { + patch := client.MergeFrom(networkContext.DeepCopy()) + if networkContext.Annotations == nil { + networkContext.Annotations = map[string]string{} + } + changed := false + for key, value := range values { + if networkContext.Annotations[key] != value { + networkContext.Annotations[key] = value + changed = true + } + } + if !changed { + return nil + } + return cl.Patch(ctx, networkContext, patch) +} + +func (r *InternalDNSProjectReconciler) clearIntent( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, +) error { + patch := client.MergeFrom(networkContext.DeepCopy()) + changed := false + for _, key := range internalDNSIntentAnnotations() { + if _, ok := networkContext.Annotations[key]; ok { + delete(networkContext.Annotations, key) + changed = true + } + } + if networkContext.Spec.DNS != nil { + networkContext.Spec.DNS = nil + changed = true + } + if !changed { + return nil + } + return cl.Patch(ctx, networkContext, patch) +} + +func (r *InternalDNSProjectReconciler) SetupWithManager(mgr mcmanager.Manager) error { + if r.Projects == nil { + return errors.New("a project identity resolver is required") + } + r.mgr = mgr + return mcbuilder.ControllerManagedBy(mgr). + For(&networkingv1alpha.NetworkContext{}, mcbuilder.WithEngageWithLocalCluster(false)). + Named("internaldns-project"). + Complete(r) +} + +type InternalDNSReadinessReconciler struct { + Config config.InternalDNSConfig + Projects ProjectClusterResolver + Now func() time.Time + + hub client.Client +} + +func (r *InternalDNSReadinessReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + hubContext := &networkingv1alpha.NetworkContext{} + if err := r.hub.Get(ctx, req.NamespacedName, hubContext); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + routing, err := resolveProjectRouting(ctx, r.hub, req.Namespace) + if err != nil { + var unresolved *projectUnresolvable + if errors.As(err, &unresolved) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + projectClient, err := r.Projects.ClientForProject(ctx, routing.project) + if err != nil { + return ctrl.Result{}, err + } + projectContext := &networkingv1alpha.NetworkContext{} + key := client.ObjectKey{Namespace: routing.projectNamespace, Name: hubContext.Name} + if err := projectClient.Get(ctx, key, projectContext); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + return r.publish(ctx, projectClient, projectContext, hubContext) +} + +func (r *InternalDNSReadinessReconciler) publish( + ctx context.Context, + cl client.Client, + projectContext *networkingv1alpha.NetworkContext, + hubContext *networkingv1alpha.NetworkContext, +) (ctrl.Result, error) { + intent, err := internalDNSSettingsFromAnnotations(projectContext.Annotations, r.Config.Frontend) + if err != nil { + return ctrl.Result{}, r.updateProject(ctx, cl, projectContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonPending, Message: err.Error(), + }) + } + + now := time.Now() + if r.Now != nil { + now = r.Now() + } + access := hubContext.Status.DNSAccess + ready := apimeta.IsStatusConditionTrue(hubContext.Status.Conditions, networkingv1alpha.NetworkContextDNSReady) + if !ready || !dnsAccessMatches(intent, access) || access == nil || !access.ValidUntil.After(now) { + err := r.updateProject(ctx, cl, projectContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkPathNotReady, + Message: "The current DNS authorization has no complete, unexpired network programming proof", + }) + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, err + } + intent.ValidUntil = access.ValidUntil + err = r.updateProject(ctx, cl, projectContext, intent, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, + Message: "DNS authorization and private network programming are current", + }) + return ctrl.Result{RequeueAfter: renewalDelay(now, access.ValidUntil.Time)}, err +} + +func (r *InternalDNSReadinessReconciler) updateProject( + ctx context.Context, + cl client.Client, + networkContext *networkingv1alpha.NetworkContext, + settings *networkingv1alpha.NetworkDNSSettings, + condition metav1.Condition, +) error { + var errs []error + if !equalDNSSettings(networkContext.Spec.DNS, settings) { + patch := client.MergeFrom(networkContext.DeepCopy()) + networkContext.Spec.DNS = settings + if err := cl.Patch(ctx, networkContext, patch); err != nil { + return err + } + } + condition.ObservedGeneration = networkContext.Generation + if apimeta.SetStatusCondition(&networkContext.Status.Conditions, condition) { + if err := cl.Status().Update(ctx, networkContext); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (r *InternalDNSReadinessReconciler) SetupWithManager(mgr manager.Manager) error { + if r.Projects == nil { + return errors.New("a project cluster resolver is required") + } + r.hub = mgr.GetClient() + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha.NetworkContext{}). + Named("internaldns-readiness"). + Complete(r) +} + +func internalDNSSettingsFromAnnotations( + annotations map[string]string, + frontend string, +) (*networkingv1alpha.NetworkDNSSettings, error) { + contextName := annotations[InternalDNSContextNameAnnotation] + contextUID := annotations[InternalDNSContextUIDAnnotation] + accessName := annotations[InternalDNSAccessNameAnnotation] + accessUID := annotations[InternalDNSAccessUIDAnnotation] + epoch, epochErr := strconv.ParseInt(annotations[InternalDNSWriterEpochAnnotation], 10, 64) + sequence, sequenceErr := strconv.ParseInt(annotations[InternalDNSSequenceAnnotation], 10, 64) + validUntil, timeErr := time.Parse(time.RFC3339Nano, annotations[InternalDNSValidUntilAnnotation]) + if contextName == "" || contextUID == "" || accessName == "" || accessUID == "" || + epochErr != nil || sequenceErr != nil || timeErr != nil { + return nil, errors.New("DNS access intent is incomplete") + } + settings := &networkingv1alpha.NetworkDNSSettings{ + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: contextName, UID: types.UID(contextUID)}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: accessName, UID: types.UID(accessUID)}, + WriterEpoch: epoch, + Sequence: sequence, + ValidUntil: metav1.NewTime(validUntil), + Nameservers: []string{frontend}, + } + if suffix := annotations[InternalDNSSuffixAnnotation]; suffix != "" { + settings.Searches = []string{suffix} + } + return settings, nil +} + +func dnsAccessMatches(settings *networkingv1alpha.NetworkDNSSettings, access *networkingv1alpha.NetworkDNSAccessStatus) bool { + return settings != nil && access != nil && + settings.ContextRef == access.ContextRef && + settings.AccessBindingRef == access.AccessBindingRef && + settings.WriterEpoch == access.WriterEpoch && + settings.Sequence == access.Sequence +} + +func equalDNSSettings(a, b *networkingv1alpha.NetworkDNSSettings) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.ContextRef == b.ContextRef && a.AccessBindingRef == b.AccessBindingRef && + a.WriterEpoch == b.WriterEpoch && a.Sequence == b.Sequence && a.ValidUntil.Equal(&b.ValidUntil) && + strings.Join(a.Nameservers, "\x00") == strings.Join(b.Nameservers, "\x00") && + strings.Join(a.Searches, "\x00") == strings.Join(b.Searches, "\x00") +} + +func internalDNSDestination(prefixText string, uid types.UID) (string, error) { + prefix, err := netip.ParsePrefix(prefixText) + if err != nil { + return "", err + } + bytes := prefix.Masked().Addr().As16() + digest := sha256.Sum256([]byte(uid)) + copy(bytes[8:], digest[:8]) + address := netip.AddrFrom16(bytes) + if !prefix.Contains(address) { + return "", fmt.Errorf("allocated DNS destination %s falls outside %s", address, prefix) + } + return address.String(), nil +} + +func internalDNSResourceName(network string, uid types.UID) string { + return boundedDNSName("dns-"+network, string(uid)) +} + +func internalDNSRegionalName(contextName, region string) string { + return boundedDNSName(contextName+"-"+region, contextName+"/"+region) +} + +func boundedDNSName(prefix, identity string) string { + sum := sha256.Sum256([]byte(identity)) + suffix := hex.EncodeToString(sum[:6]) + maxPrefix := 253 - len(suffix) - 1 + prefix = strings.Trim(prefix, "-") + if len(prefix) > maxPrefix { + prefix = strings.TrimRight(prefix[:maxPrefix], "-") + } + return prefix + "-" + suffix +} + +func bindingReady(binding *unstructured.Unstructured, sequence int64) bool { + observed, found, _ := unstructured.NestedInt64(binding.Object, "status", "observedSequence") + bindingUID, _, _ := unstructured.NestedString(binding.Object, "status", "bindingRef", "uid") + return found && observed == sequence && bindingUID != "" && unstructuredConditionTrue(binding, "Ready") +} + +func unstructuredConditionTrue(obj *unstructured.Unstructured, conditionType string) bool { + conditions, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") + for _, raw := range conditions { + condition, _ := raw.(map[string]any) + if condition["type"] == conditionType && condition["status"] == "True" { + return true + } + } + return false +} + +func objectTime(obj *unstructured.Unstructured, fields ...string) (time.Time, error) { + text, found, err := unstructured.NestedString(obj.Object, fields...) + if err != nil || !found { + return time.Time{}, fmt.Errorf("%s has no %s", obj.GetName(), strings.Join(fields, ".")) + } + value, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return time.Time{}, fmt.Errorf("%s has invalid %s: %w", obj.GetName(), strings.Join(fields, "."), err) + } + return value, nil +} + +func newInternalDNSObject(gvk schema.GroupVersionKind, namespace, name string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetNamespace(namespace) + obj.SetName(name) + return obj +} + +func renewalDelay(now time.Time, validUntil time.Time) time.Duration { + delay := validUntil.Sub(now) - internalDNSRenewBefore + if delay < time.Second { + return time.Second + } + return delay +} + +func internalDNSIntentAnnotations() []string { + return []string{ + InternalDNSContextNameAnnotation, + InternalDNSContextUIDAnnotation, + InternalDNSAccessNameAnnotation, + InternalDNSAccessUIDAnnotation, + InternalDNSWriterEpochAnnotation, + InternalDNSSequenceAnnotation, + InternalDNSValidUntilAnnotation, + InternalDNSServiceDestinationAnnotation, + InternalDNSSuffixAnnotation, + } +} + +func boolPtr(value bool) *bool { return &value } diff --git a/internal/controller/networkinterface_projection.go b/internal/controller/networkinterface_projection.go index a3249f48..e648a9fd 100644 --- a/internal/controller/networkinterface_projection.go +++ b/internal/controller/networkinterface_projection.go @@ -38,6 +38,7 @@ func projectedInterface(source *networkingv1alpha.NetworkInterface, location str Addresses: append([]networkingv1alpha.NetworkInterfaceAddress(nil), source.Spec.Addresses...), ExternalAddresses: append([]networkingv1alpha.NetworkInterfaceExternalAddress(nil), source.Spec.ExternalAddresses...), ReclaimPolicy: source.Spec.ReclaimPolicy, + DNS: source.Spec.DNS.DeepCopy(), }, Status: networkingv1alpha.NetworkInterfaceStatus{ Phase: source.Status.Phase, diff --git a/internal/controller/networkinterfaceclaim_controller.go b/internal/controller/networkinterfaceclaim_controller.go index c4d56e34..52e54031 100644 --- a/internal/controller/networkinterfaceclaim_controller.go +++ b/internal/controller/networkinterfaceclaim_controller.go @@ -14,6 +14,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -1023,6 +1024,10 @@ func (r *NetworkInterfaceClaimReconciler) syncInterface( iface.Spec.MTU = networkContext.Spec.MTU changed = true } + if !apiequality.Semantic.DeepEqual(iface.Spec.DNS, networkContext.Spec.DNS) { + iface.Spec.DNS = networkContext.Spec.DNS.DeepCopy() + changed = true + } // An interface adopted from before the field existed carries none. if claim.Spec.AttachmentMode != "" && iface.Spec.AttachmentMode != claim.Spec.AttachmentMode { iface.Spec.AttachmentMode = claim.Spec.AttachmentMode diff --git a/internal/dnsaccess/programming.go b/internal/dnsaccess/programming.go new file mode 100644 index 00000000..03246079 --- /dev/null +++ b/internal/dnsaccess/programming.go @@ -0,0 +1,244 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package dnsaccess + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +// ProgrammingRequirements fences readiness to the complete service topology. +type ProgrammingRequirements struct { + InputDigest string + Nodes []string +} + +// ProgrammingPlan returns the exact digest and node set Galactic must report +// before network services publishes resolver settings. +func ProgrammingPlan( + policy *networkv1alpha1.ServiceRoutePolicy, + endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, + consumerVPCIdentity string, +) (ProgrammingRequirements, error) { + plan := ProgrammingRequirements{} + if policy == nil || endpoint == nil { + return plan, errors.New("policy and endpoint are required") + } + if policy.Spec.Frontend != nil && (policy.UID == "" || endpoint.UID == "" || consumerVPCIdentity == "") { + return plan, errors.New("translated programming requires current policy, endpoint, and VPC identities") + } + selector, err := metav1.LabelSelectorAsSelector(&policy.Spec.AttachmentSelector) + if err != nil { + return plan, err + } + producers, err := producerCandidates(endpoint, attachments) + if err != nil { + return plan, err + } + if err := validateProducerTopology(endpoint, producers); err != nil { + return plan, err + } + ready := map[types.NamespacedName]bool{} + for _, attachment := range readyAttachments(attachments) { + ready[types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}] = + attachment.Status.HostInterface != "" + } + relevant := map[types.NamespacedName]bool{} + for _, producer := range producers { + relevant[types.NamespacedName{Namespace: producer.Namespace, Name: producer.Name}] = true + } + nodes := map[string]bool{} + selected := 0 + sorted := nonNilAttachments(attachments) + for _, attachment := range sorted { + if !selector.Matches(labels.Set(attachment.Labels)) || !consumerMatchesVPC(policy, attachment, consumerVPCIdentity) { + continue + } + key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name} + relevant[key] = true + selected++ + if attachment.DeletionTimestamp != nil || !ready[key] { + return plan, fmt.Errorf("consumer attachment %s is not ready", key) + } + producer := chooseProducer(endpoint.Spec.DeliveryMode, attachment.Status.Node, producers) + if producer == nil { + return plan, fmt.Errorf("consumer attachment %s has no eligible producer", key) + } + if policy.Spec.Frontend != nil && (attachment.UID == "" || producer.UID == "") { + return plan, errors.New("translated programming requires current attachment UIDs") + } + nodes[attachment.Status.Node] = true + nodes[producer.Status.Node] = true + } + if selected == 0 { + return plan, errors.New("no eligible consumer attachments") + } + + snapshot := struct { + PolicyUID types.UID + PolicyGeneration int64 + PolicySpec networkv1alpha1.ServiceRoutePolicySpec + EndpointUID types.UID + EndpointGeneration int64 + EndpointSpec networkv1alpha1.ServiceEndpointSpec + VPCIdentity string + Attachments []any + }{ + PolicyUID: policy.UID, PolicyGeneration: policy.Generation, PolicySpec: policy.Spec, + EndpointUID: endpoint.UID, EndpointGeneration: endpoint.Generation, EndpointSpec: endpoint.Spec, + VPCIdentity: consumerVPCIdentity, + } + for _, attachment := range sorted { + key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name} + if !relevant[key] { + continue + } + snapshot.Attachments = append(snapshot.Attachments, struct { + Namespace, Name string + UID types.UID + Generation int64 + Labels map[string]string + Deleted bool + Spec cloudv1alpha1.VPCAttachmentSpec + Status cloudv1alpha1.VPCAttachmentStatus + }{ + attachment.Namespace, attachment.Name, attachment.UID, attachment.Generation, + attachment.Labels, attachment.DeletionTimestamp != nil, attachment.Spec, attachment.Status, + }) + } + encoded, err := json.Marshal(snapshot) + if err != nil { + return plan, err + } + sum := sha256.Sum256(encoded) + plan.InputDigest = hex.EncodeToString(sum[:]) + for node := range nodes { + plan.Nodes = append(plan.Nodes, node) + } + sort.Strings(plan.Nodes) + return plan, nil +} + +func nonNilAttachments(attachments []*cloudv1alpha1.VPCAttachment) []*cloudv1alpha1.VPCAttachment { + result := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments)) + for _, attachment := range attachments { + if attachment != nil { + result = append(result, attachment) + } + } + sort.Slice(result, func(i, j int) bool { + return result[i].Namespace+"/"+result[i].Name < result[j].Namespace+"/"+result[j].Name + }) + return result +} + +func readyAttachments(attachments []*cloudv1alpha1.VPCAttachment) []*cloudv1alpha1.VPCAttachment { + result := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments)) + for _, attachment := range attachments { + if attachment == nil || attachment.DeletionTimestamp != nil || + attachment.Status.ObservedGeneration != attachment.Generation || + attachment.Status.Node == "" || attachment.Status.VPC == "" || attachment.Status.VPCAttachment == "" || + !apimeta.IsStatusConditionTrue(attachment.Status.Conditions, cloudv1alpha1.ConditionTypeReady) || + !apimeta.IsStatusConditionTrue(attachment.Status.Conditions, cloudv1alpha1.ConditionTypeProgrammed) { + continue + } + result = append(result, attachment) + } + sort.Slice(result, func(i, j int) bool { + return types.NamespacedName{Namespace: result[i].Namespace, Name: result[i].Name}.String() < + types.NamespacedName{Namespace: result[j].Namespace, Name: result[j].Name}.String() + }) + return result +} + +func producerCandidates( + endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, +) ([]*cloudv1alpha1.VPCAttachment, error) { + if (endpoint.Spec.AttachmentRef == nil) == (endpoint.Spec.AttachmentSelector == nil) { + return nil, errors.New("service endpoint must select one producer source") + } + ready := readyAttachments(attachments) + if endpoint.Spec.AttachmentRef != nil { + key := types.NamespacedName{ + Namespace: endpoint.Spec.AttachmentRef.Namespace, + Name: endpoint.Spec.AttachmentRef.Name, + } + for _, attachment := range ready { + if (types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}) == key && + attachment.Status.HostInterface != "" { + return []*cloudv1alpha1.VPCAttachment{attachment}, nil + } + } + return nil, nil + } + selector, err := metav1.LabelSelectorAsSelector(endpoint.Spec.AttachmentSelector) + if err != nil { + return nil, err + } + result := make([]*cloudv1alpha1.VPCAttachment, 0, len(ready)) + for _, attachment := range ready { + if attachment.Status.HostInterface != "" && selector.Matches(labels.Set(attachment.Labels)) { + result = append(result, attachment) + } + } + return result, nil +} + +func validateProducerTopology( + endpoint *networkv1alpha1.ServiceEndpoint, + producers []*cloudv1alpha1.VPCAttachment, +) error { + type location struct{ node, vpc string } + seen := map[location]bool{} + for _, producer := range producers { + key := location{node: producer.Status.Node, vpc: producer.Status.VPC} + if seen[key] { + return fmt.Errorf("service endpoint %s selects multiple producers on node %q in VPC %q", endpoint.Name, key.node, key.vpc) + } + seen[key] = true + } + return nil +} + +func chooseProducer( + mode networkv1alpha1.ServiceEndpointDeliveryMode, + consumerNode string, + producers []*cloudv1alpha1.VPCAttachment, +) *cloudv1alpha1.VPCAttachment { + for _, producer := range producers { + if producer.Status.Node == consumerNode { + return producer + } + } + if mode == networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal && len(producers) > 0 { + return producers[0] + } + return nil +} + +func consumerMatchesVPC( + policy *networkv1alpha1.ServiceRoutePolicy, + consumer *cloudv1alpha1.VPCAttachment, + identity string, +) bool { + if ref := policy.Spec.ConsumerVPCRef; ref != nil && + (consumer.Namespace != policy.Namespace || consumer.Spec.VPC.Name != ref.Name) { + return false + } + return identity == "" || consumer.Status.VPC == identity +} From e1d683623113d188c9d1aa4520db625cda53989c Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 9 Oct 2026 10:05:58 -0500 Subject: [PATCH 03/25] test: Cover internal DNS lifecycle and readiness --- internal/config/config_test.go | 30 ++ .../internaldns_cell_controller_test.go | 168 +++++++++ .../controller/internaldns_controller_test.go | 338 ++++++++++++++++++ .../networkinterface_projection_test.go | 21 ++ 4 files changed, 557 insertions(+) create mode 100644 internal/controller/internaldns_cell_controller_test.go create mode 100644 internal/controller/internaldns_controller_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5780a786..7b7999df 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,8 +5,38 @@ import ( "path/filepath" "strings" "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func TestInternalDNSDefaultsRemainDisabled(t *testing.T) { + cfg := &NetworkServicesOperator{} + SetObjectDefaults_NetworkServicesOperator(cfg) + if cfg.InternalDNS.Enabled { + t.Fatal("internal DNS must require an explicit alpha feature gate") + } + if cfg.InternalDNS.Frontend != "fd53::53" || cfg.InternalDNS.ServicePrefix != "fd70:100::/64" || + cfg.InternalDNS.AccessLeaseDuration.Duration != 2*time.Minute { + t.Fatalf("unexpected internal DNS defaults: %#v", cfg.InternalDNS) + } +} + +func TestInternalDNSValidation(t *testing.T) { + cfg := InternalDNSConfig{ + Enabled: true, Frontend: "fd53::53", ServicePrefix: "fd70:100::/64", + AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}, + ProducerAttachmentSelector: map[string]string{"service.datum.net/internal-dns": "true"}, + } + if err := cfg.validate(); err != nil { + t.Fatalf("valid config rejected: %v", err) + } + cfg.AccessLeaseDuration = metav1.Duration{Duration: 2*time.Minute + time.Second} + if err := cfg.validate(); err == nil { + t.Fatal("authorization beyond Galactic's accepted deadline was allowed") + } +} + func TestNetworkServicesOperator_Validate_IrohDisabled(t *testing.T) { // When DNSEnabled is false the rest of the iroh config is allowed to // be empty — nothing depends on it. diff --git a/internal/controller/internaldns_cell_controller_test.go b/internal/controller/internaldns_cell_controller_test.go new file mode 100644 index 00000000..f667bad1 --- /dev/null +++ b/internal/controller/internaldns_cell_controller_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + "time" + + 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" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/dnsaccess" +) + +func TestPolicyProgrammedRequiresExactCurrentNodeReports(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) + deadline := metav1.NewTime(now.Add(time.Minute)) + consumer := readyAttachment("tenant", "consumer", "consumer-uid", "blue", "vpc-fabric-id", "node-a", "consumer0", map[string]string{ + internalDNSConsumerVPCLabel: "vpc-uid", + }) + producer := readyAttachment("tenant", "producer", "producer-uid", "dns-service", "service-vpc-id", "node-b", "producer0", map[string]string{ + "service.datum.net/internal-dns": "true", + }) + endpoint := &networkv1alpha1.ServiceEndpoint{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "dns", UID: "endpoint-uid", Generation: 2}, + Spec: networkv1alpha1.ServiceEndpointSpec{ + ServiceClass: "internal-dns", Address: "fd70:100::10", Port: 53, + Protocol: networkv1alpha1.NetworkRuleProtocolUDP, + DeliveryMode: networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal, + AttachmentSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ + "service.datum.net/internal-dns": "true", + }}, + }, + } + policy := &networkv1alpha1.ServiceRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "dns", UID: "policy-uid", Generation: 3}, + Spec: networkv1alpha1.ServiceRoutePolicySpec{ + ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: endpoint.Name}, + ConsumerVPCRef: &networkv1alpha1.ServiceRouteVPCReference{Name: "blue", UID: "vpc-uid"}, + Frontend: &networkv1alpha1.ServiceRouteFrontend{Address: "fd53::53"}, + Authorization: &networkv1alpha1.ServiceRouteAuthorization{ValidUntil: deadline}, + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{ + internalDNSConsumerVPCLabel: "vpc-uid", + }}, + ProtocolPorts: []networkv1alpha1.ServiceRouteProtocolPort{{ + Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53, + }}, + }, + Status: networkv1alpha1.ServiceRoutePolicyStatus{ + ObservedGeneration: 3, + Conditions: []metav1.Condition{{ + Type: networkv1alpha1.ConditionTypeServiceRouteAccepted, Status: metav1.ConditionTrue, + Reason: "Accepted", + }}, + }, + } + attachments := []*cloudv1alpha1.VPCAttachment{consumer, producer} + plan, err := dnsaccess.ProgrammingPlan(policy, endpoint, attachments, "vpc-fabric-id") + if err != nil { + t.Fatal(err) + } + for _, node := range plan.Nodes { + policy.Status.Nodes = append(policy.Status.Nodes, networkv1alpha1.ServiceRouteNodeStatus{ + NodeName: node, PolicyUID: string(policy.UID), ObservedGeneration: policy.Generation, + InputDigest: plan.InputDigest, Ready: true, ValidUntil: deadline, + }) + } + if until, ready := policyProgrammed(policy, endpoint, attachments, "vpc-fabric-id", now); !ready || !until.Equal(deadline.Time) { + t.Fatalf("current exact reports not accepted: ready=%v until=%v", ready, until) + } + + policy.Status.Nodes[0].InputDigest = "stale" + if _, ready := policyProgrammed(policy, endpoint, attachments, "vpc-fabric-id", now); ready { + t.Fatal("stale input digest was accepted") + } + policy.Status.Nodes[0].InputDigest = plan.InputDigest + policy.Status.Nodes[0].ValidUntil = metav1.NewTime(now.Add(-time.Second)) + if _, ready := policyProgrammed(policy, endpoint, attachments, "vpc-fabric-id", now); ready { + t.Fatal("expired node report was accepted") + } +} + +func TestNetworkAuthorizationDeadlineNeverOutlivesDNS(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) + dnsDeadline := now.Add(time.Minute) + policy := &networkv1alpha1.ServiceRoutePolicy{Spec: networkv1alpha1.ServiceRoutePolicySpec{ + Authorization: &networkv1alpha1.ServiceRouteAuthorization{ + ValidUntil: metav1.NewTime(now.Add(90 * time.Second)), + }, + }} + if got := networkAuthorizationDeadline(policy, now, dnsDeadline); !got.Equal(dnsDeadline) { + t.Fatalf("authorization deadline = %v, want DNS deadline %v", got, dnsDeadline) + } +} + +func TestConsumerVPCRejectsMultipleVPCLifetimes(t *testing.T) { + t.Parallel() + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + contextObject := testNetworkContext("context", "context-uid") + firstInterface, firstAttachment, firstVPC := consumerPath("one", contextObject.Name, "vpc-one", "vpc-one-uid", "fabric-one") + secondInterface, secondAttachment, secondVPC := consumerPath("two", contextObject.Name, "vpc-two", "vpc-two-uid", "fabric-two") + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + firstInterface, firstAttachment, firstVPC, + secondInterface, secondAttachment, secondVPC, + ).Build() + r := &InternalDNSCellReconciler{} + if _, _, err := r.consumerVPC(context.Background(), cl, contextObject); err == nil { + t.Fatal("multiple VPC lifetimes were accepted for one network context") + } +} + +func readyAttachment( + namespace, name, uid, vpcName, vpcIdentity, node, hostInterface string, + labels map[string]string, +) *cloudv1alpha1.VPCAttachment { + return &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: types.UID(uid), Generation: 1, Labels: labels}, + Spec: cloudv1alpha1.VPCAttachmentSpec{VPC: cloudv1alpha1.VPCRef{Name: vpcName}}, + Status: cloudv1alpha1.VPCAttachmentStatus{ + ObservedGeneration: 1, VPC: vpcIdentity, VPCAttachment: name + "-fabric", + Node: node, HostInterface: hostInterface, + Conditions: []metav1.Condition{ + {Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue, Reason: "Ready"}, + {Type: cloudv1alpha1.ConditionTypeProgrammed, Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + } +} + +func consumerPath( + name, contextName, vpcName string, + vpcUID types.UID, + vpcIdentity string, +) (*networkingv1alpha.NetworkInterface, *cloudv1alpha1.VPCAttachment, *cloudv1alpha1.VPC) { + iface := &networkingv1alpha.NetworkInterface{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "interface-" + name}, + Status: networkingv1alpha.NetworkInterfaceStatus{ + NetworkContextRef: &networkingv1alpha.LocalNetworkContextRef{Name: contextName}, + AttachmentRef: &networkingv1alpha.NetworkInterfaceAttachmentRef{ + APIGroup: "cloud.datumapis.com", Kind: "VPCAttachment", Name: "attachment-" + name, + }, + }, + } + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "attachment-" + name}, + Spec: cloudv1alpha1.VPCAttachmentSpec{VPC: cloudv1alpha1.VPCRef{Name: vpcName}}, + } + vpc := &cloudv1alpha1.VPC{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: vpcName, UID: vpcUID}, + Status: cloudv1alpha1.VPCStatus{VPC: vpcIdentity}, + } + return iface, attachment, vpc +} diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go new file mode 100644 index 00000000..9f64f51d --- /dev/null +++ b/internal/controller/internaldns_controller_test.go @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" +) + +type fixedProjectIdentity types.UID + +func (f fixedProjectIdentity) ProjectUID(context.Context, string) (types.UID, error) { + return types.UID(f), nil +} + +func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := locationsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + scheme.AddKnownTypeWithName(internalDNSContextGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(internalDNSContextGVK.GroupVersion().WithKind("DNSResolverContextList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(internalDNSAccessGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(internalDNSAccessGVK.GroupVersion().WithKind("DNSResolverAccessBindingList"), &unstructured.UnstructuredList{}) + + network := &networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "blue", UID: "network-uid"}, + } + location := &locationsv1alpha1.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "ord-1"}, + Spec: locationsv1alpha1.LocationSpec{Topology: map[string]string{internalDNSRegionTopologyKey: "us-central"}}, + } + first := testNetworkContext("first", "context-1") + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(network, location, first).Build() + r := &InternalDNSProjectReconciler{ + Config: config.InternalDNSConfig{ + Frontend: "fd53::53", ServicePrefix: "fd70:100::/64", + AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}, + }, + Projects: fixedProjectIdentity("project-uid"), + Now: func() time.Time { return now }, + } + + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + dnsContext := newInternalDNSObject(internalDNSContextGVK, "tenant", internalDNSResourceName("blue", network.UID)) + if err := cl.Get(ctx, client.ObjectKeyFromObject(dnsContext), dnsContext); err != nil { + t.Fatal(err) + } + dnsContext.SetUID("dns-context-uid") + if err := unstructured.SetNestedField(dnsContext.Object, int64(7), "status", "accessWriterEpoch"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(dnsContext.Object, "blue.internal", "status", "managedNamespace", "suffix"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedMap(dnsContext.Object, map[string]any{ + "name": "blue-internal", "uid": "zone-uid", + }, "status", "managedNamespace", "dnsZoneRef"); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, dnsContext); err != nil { + t.Fatal(err) + } + + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + bindingName := internalDNSRegionalName(dnsContext.GetName(), "us-central") + binding := newInternalDNSObject(internalDNSAccessGVK, "tenant", bindingName) + if err := cl.Get(ctx, client.ObjectKeyFromObject(binding), binding); err != nil { + t.Fatal(err) + } + binding.SetUID("access-uid") + if err := unstructured.SetNestedField(binding.Object, int64(1), "status", "observedSequence"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(binding.Object, "dns-binding-uid", "status", "bindingRef", "uid"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedSlice(binding.Object, []any{map[string]any{ + "type": "Ready", "status": "True", "reason": "Ready", + }}, "status", "conditions"); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, binding); err != nil { + t.Fatal(err) + } + + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + assertDNSIntent(t, ctx, cl, "first", "access-uid", "1") + + second := testNetworkContext("second", "context-2") + if err := cl.Create(ctx, second); err != nil { + t.Fatal(err) + } + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(second)); err != nil { + t.Fatal(err) + } + assertDNSIntent(t, ctx, cl, "second", "access-uid", "1") + assertUnstructuredCount(t, ctx, cl, internalDNSContextGVK, "tenant", 1) + assertUnstructuredCount(t, ctx, cl, internalDNSAccessGVK, "tenant", 1) + firstCurrent := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "first"}, firstCurrent); err != nil { + t.Fatal(err) + } + if err := r.cleanupRegion(ctx, cl, firstCurrent); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { + t.Fatalf("regional access was removed while another context remained: %v", err) + } + + now = now.Add(3 * time.Minute) + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + current := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "first"}, current); err != nil { + t.Fatal(err) + } + if current.Annotations[InternalDNSAccessUIDAnnotation] != "" { + t.Fatalf("expired access remained published: %#v", current.Annotations) + } + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { + t.Fatal(err) + } + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + if sequence != 2 { + t.Fatalf("renewed sequence = %d, want 2", sequence) + } + secondCurrent := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "second"}, secondCurrent); err != nil { + t.Fatal(err) + } + if err := cl.Delete(ctx, firstCurrent); err != nil { + t.Fatal(err) + } + if err := r.cleanupRegion(ctx, cl, secondCurrent); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); !apierrors.IsNotFound(err) { + t.Fatalf("regional access survived the last context: %v", err) + } +} + +func TestInternalDNSReadinessRequiresExactUnexpiredProof(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + projectContext := testNetworkContext("context", "project-context-uid") + projectContext.Annotations = testDNSIntent(now.Add(time.Minute)) + cl := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&networkingv1alpha.NetworkContext{}). + WithObjects(projectContext).Build() + hubContext := projectContext.DeepCopy() + hubContext.Status.DNSAccess = &networkingv1alpha.NetworkDNSAccessStatus{ + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver", UID: "resolver-uid"}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, + WriterEpoch: 4, + Sequence: 9, + ValidUntil: metav1.NewTime(now.Add(time.Minute)), + } + hubContext.Status.Conditions = []metav1.Condition{{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, + Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, + }} + r := &InternalDNSReadinessReconciler{ + Config: config.InternalDNSConfig{Frontend: "fd53::53"}, + Now: func() time.Time { return now }, + } + + if _, err := r.publish(ctx, cl, projectContext, hubContext); err != nil { + t.Fatal(err) + } + current := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { + t.Fatal(err) + } + if current.Spec.DNS == nil || current.Spec.DNS.Sequence != 9 { + t.Fatalf("verified settings were not published: %#v", current.Spec.DNS) + } + + hubContext.Status.DNSAccess.Sequence = 8 + if _, err := r.publish(ctx, cl, current, hubContext); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { + t.Fatal(err) + } + if current.Spec.DNS != nil { + t.Fatalf("settings survived mismatched proof: %#v", current.Spec.DNS) + } + + hubContext.Status.DNSAccess.Sequence = 9 + r.Now = func() time.Time { return now.Add(2 * time.Minute) } + if _, err := r.publish(ctx, cl, current, hubContext); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { + t.Fatal(err) + } + if current.Spec.DNS != nil { + t.Fatalf("settings survived proof expiry: %#v", current.Spec.DNS) + } +} + +func TestAccessBindingWriterEpochChangeStartsNewFence(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(internalDNSAccessGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(internalDNSAccessGVK.GroupVersion().WithKind("DNSResolverAccessBindingList"), &unstructured.UnstructuredList{}) + network := &networkingv1alpha.Network{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "blue", UID: "network-uid"}} + dnsContext := newInternalDNSObject(internalDNSContextGVK, "tenant", "resolver") + dnsContext.SetUID("resolver-uid") + binding := newInternalDNSObject(internalDNSAccessGVK, "tenant", internalDNSRegionalName("resolver", "us-central")) + binding.SetUID("access-uid") + binding.SetAnnotations(map[string]string{internalDNSOwnerNetworkUIDAnnotation: string(network.UID)}) + binding.Object["spec"] = map[string]any{ + "contextRef": map[string]any{"name": "resolver", "uid": "resolver-uid"}, + "region": "us-central", + "queryIdentity": map[string]any{"type": "DestinationAddress", "value": "fd70:100::10"}, + "authorization": map[string]any{ + "writerEpoch": int64(7), "sequence": int64(12), + "validUntil": now.Add(time.Minute).Format(time.RFC3339Nano), + }, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(binding).Build() + r := &InternalDNSProjectReconciler{ + Config: config.InternalDNSConfig{AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}}, + Now: func() time.Time { return now }, + } + + _, ready, err := r.ensureAccess(ctx, cl, network, "project-uid", dnsContext, "us-central", "fd70:100::10", 8) + if err != nil { + t.Fatal(err) + } + if ready { + t.Fatal("a binding from the previous writer epoch was accepted") + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(binding), binding); err != nil { + t.Fatal(err) + } + epoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "writerEpoch") + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + if epoch != 8 || sequence != 1 { + t.Fatalf("new fence = epoch %d sequence %d, want epoch 8 sequence 1", epoch, sequence) + } +} + +func testNetworkContext(name string, uid types.UID) *networkingv1alpha.NetworkContext { + return &networkingv1alpha.NetworkContext{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "tenant", Name: name, UID: uid, + Labels: map[string]string{networkingv1alpha.NetworkUIDLabel: "network-uid"}, + }, + Spec: networkingv1alpha.NetworkContextSpec{ + Network: networkingv1alpha.LocalNetworkRef{Name: "blue"}, + Location: locationsv1alpha1.LocationReference{Name: "ord-1"}, + }, + } +} + +func testDNSIntent(validUntil time.Time) map[string]string { + return map[string]string{ + InternalDNSContextNameAnnotation: "resolver", + InternalDNSContextUIDAnnotation: "resolver-uid", + InternalDNSAccessNameAnnotation: "access", + InternalDNSAccessUIDAnnotation: "access-uid", + InternalDNSWriterEpochAnnotation: "4", + InternalDNSSequenceAnnotation: "9", + InternalDNSValidUntilAnnotation: validUntil.Format(time.RFC3339Nano), + InternalDNSSuffixAnnotation: "blue.internal", + InternalDNSRegionAnnotation: "us-central", + InternalDNSServiceDestinationAnnotation: "fd70:100::10", + } +} + +func assertDNSIntent(t *testing.T, ctx context.Context, cl client.Client, name, uid, sequence string) { + t.Helper() + current := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: name}, current); err != nil { + t.Fatal(err) + } + if current.Annotations[InternalDNSAccessUIDAnnotation] != uid || + current.Annotations[InternalDNSSequenceAnnotation] != sequence { + t.Fatalf("unexpected DNS intent: %#v", current.Annotations) + } +} + +func assertUnstructuredCount( + t *testing.T, + ctx context.Context, + cl client.Client, + gvk schema.GroupVersionKind, + namespace string, + want int, +) { + t.Helper() + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(gvk.GroupVersion().WithKind(gvk.Kind + "List")) + if err := cl.List(ctx, list, client.InNamespace(namespace)); err != nil { + t.Fatal(err) + } + if len(list.Items) != want { + t.Fatalf("%s count = %d, want %d", gvk.Kind, len(list.Items), want) + } +} diff --git a/internal/controller/networkinterface_projection_test.go b/internal/controller/networkinterface_projection_test.go index cc3b854e..54f02af8 100644 --- a/internal/controller/networkinterface_projection_test.go +++ b/internal/controller/networkinterface_projection_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -359,6 +360,26 @@ func TestInterfaceReachesTheProjectControlPlane(t *testing.T) { require.Equal(t, boundInterfaceName, copied.Labels[networkingv1alpha.NetworkInterfaceHolderLabel]) } +func TestProjectedInterfaceCarriesDNSFence(t *testing.T) { + validUntil := metav1.NewTime(time.Date(2026, time.October, 9, 12, 1, 0, 0, time.UTC)) + source := &networkingv1alpha.NetworkInterface{Spec: networkingv1alpha.NetworkInterfaceSpec{ + DNS: &networkingv1alpha.NetworkDNSSettings{ + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver", UID: "resolver-uid"}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, + WriterEpoch: 4, + Sequence: 9, + ValidUntil: validUntil, + Nameservers: []string{"fd53::53"}, + Searches: []string{"blue.internal"}, + }, + }} + + projection := projectedInterface(source, "ord-1") + require.Equal(t, source.Spec.DNS, projection.Spec.DNS) + projection.Spec.DNS.Nameservers[0] = "fd53::54" + require.Equal(t, "fd53::53", source.Spec.DNS.Nameservers[0], "the projection must own its DNS settings") +} + func TestCopyDropsWhatOnlyMeansSomethingOnTheCell(t *testing.T) { v := newVisibility(t) v.interfaceOnCell() From 4fbdb210b3308d9314eb4de6b3f4406b297b66f5 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 9 Oct 2026 14:07:22 -0500 Subject: [PATCH 04/25] fix federation controllers to watch hub API --- internal/cmd/manager/manager.go | 80 +++++++++++++++------------------ 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index 291e3c77..a14fa889 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -149,13 +149,13 @@ func NewCommand(build BuildInfo) *cobra.Command { &singletonControllersLeaderElection, "singleton-controllers-leader-elect", true, - "Enable leader election for singleton downstream controllers (Challenge and GatewayDownstreamCertificateSolver).", + "Enable leader election for controllers that watch the federation hub.", ) fs.StringVar( &singletonControllersLeaderElectionID, "singleton-controllers-leader-election-id", "", - "Leader election ID for singleton downstream controllers. When empty, it is derived from the enabled controller sets.", + "Leader election ID for controllers that watch the federation hub. When empty, it is derived from the enabled controller sets.", ) opts := zap.Options{ @@ -345,26 +345,24 @@ func NewCommand(build BuildInfo) *cobra.Command { os.Exit(1) } - var singletonMgr manager.Manager - singletonControllerMgr := mgr.GetLocalManager() - if enableClusterSharding { - singletonMgr, err = manager.New(cfg, manager.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{BindAddress: "0"}, - WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), - HealthProbeBindAddress: "0", - LeaderElection: singletonControllersLeaderElection, - LeaderElectionID: singletonControllersLeaderElectionID, - LeaderElectionNamespace: leaderElectionNamespace, - LeaseDuration: &leaseDuration, - RenewDeadline: &renewDeadline, - RetryPeriod: &retryPeriod, - }) - if err != nil { - setupLog.Error(err, "unable to create singleton controller manager") - os.Exit(1) - } - singletonControllerMgr = singletonMgr + // Hub controllers reconcile NetworkBindings and resources published by + // cells. Keep their cache and leader election on the federation API, + // alongside the authoritative copies they watch. + federationMgr, err := manager.New(downstreamRestConfig, manager.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), + HealthProbeBindAddress: "0", + LeaderElection: singletonControllersLeaderElection, + LeaderElectionID: singletonControllersLeaderElectionID, + LeaderElectionNamespace: leaderElectionNamespace, + LeaseDuration: &leaseDuration, + RenewDeadline: &renewDeadline, + RetryPeriod: &retryPeriod, + }) + if err != nil { + setupLog.Error(err, "unable to create federation controller manager") + os.Exit(1) } var irohDownstream cluster.Cluster @@ -413,7 +411,7 @@ func NewCommand(build BuildInfo) *cobra.Command { registeredControllers, err := setupControllers(mgr, serverConfig, controllerDeps{ downstreamCluster: downstreamCluster, - singletonManager: singletonControllerMgr, + federationManager: federationMgr, irohDownstream: irohDownstream, ipamClients: ipamClients, certificateServiceReader: certificateServiceReader, @@ -492,12 +490,10 @@ func NewCommand(build BuildInfo) *cobra.Command { return ignoreCanceled(mgr.Start(ctx)) }) - if singletonMgr != nil { - setupLog.Info("starting singleton controller manager") - g.Go(func() error { - return ignoreCanceled(singletonMgr.Start(ctx)) - }) - } + setupLog.Info("starting federation controller manager") + g.Go(func() error { + return ignoreCanceled(federationMgr.Start(ctx)) + }) if err := g.Wait(); err != nil { setupLog.Error(err, "unable to start") @@ -593,7 +589,7 @@ func setupWebhooks(mgr mcmanager.Manager, serverConfig config.NetworkServicesOpe // against. Fields are only populated for the sets that need them. type controllerDeps struct { downstreamCluster cluster.Cluster - singletonManager manager.Manager + federationManager manager.Manager irohDownstream cluster.Cluster ipamClients controller.IPAMClientFactory certificateServiceReader client.Reader @@ -698,17 +694,14 @@ func controllerRegistrations( SubnetClass: serverConfig.IPAM.Classes.Subnet, }).SetupWithManager(mgr) }}, - // The deployment cluster is the hub, and the milo provider engages - // project control planes in the same process. The presence controller - // goes on the singleton manager because the sharded managers run three - // replicas with leader election disabled, which would reconcile every - // hub object three times. + // Bindings are authoritative on the federation hub. Project control + // planes are reached through the multicluster manager in the same process. {"networkpresence", true, func() error { return (&controller.NetworkPresenceReconciler{ Projects: controller.NewProjectClusterResolver(mgr), Events: presenceEvents, UnclaimedGracePeriod: serverConfig.NetworkPresence.UnclaimedGracePeriod.Duration, - }).SetupWithManager(deps.singletonManager) + }).SetupWithManager(deps.federationManager) }}, // Everything the presence controller reads besides the binding lives in a // project control plane, which only the multicluster manager watches. The @@ -717,7 +710,7 @@ func controllerRegistrations( {"networkpresencesync", true, func() error { return (&controller.NetworkPresenceSyncReconciler{ Events: presenceEvents, - }).SetupWithManager(mgr, deps.singletonManager) + }).SetupWithManager(mgr, deps.federationManager) }}, {"internaldns-project", serverConfig.InternalDNS.Enabled, func() error { return (&controller.InternalDNSProjectReconciler{ @@ -729,17 +722,14 @@ func controllerRegistrations( return (&controller.InternalDNSReadinessReconciler{ Config: serverConfig.InternalDNS, Projects: controller.NewProjectClusterResolver(mgr), - }).SetupWithManager(deps.singletonManager) + }).SetupWithManager(deps.federationManager) }}, - // The interfaces a cell publishes arrive on the hub, and only the + // The interfaces a cell publishes arrive on the federation hub, and the // multicluster manager reaches the project control planes they are for. - // The projector goes on the singleton manager for the same reason the - // presence controller does: three replicas would project every one of - // them three times. {"networkinterfaceprojector", true, func() error { return (&controller.NetworkInterfaceProjector{ Projects: controller.NewProjectClusterResolver(mgr), - }).SetupWithManager(deps.singletonManager) + }).SetupWithManager(deps.federationManager) }}, // Removing a copy is the other half, and it has to watch project control // planes: a published interface that has gone says nothing about where @@ -809,7 +799,7 @@ func controllerRegistrations( return (&controller.GatewayDownstreamCertificateSolverReconciler{ Config: serverConfig, DownstreamCluster: deps.downstreamCluster, - }).SetupWithManager(deps.singletonManager) + }).SetupWithManager(deps.federationManager) }}, {"domain", true, func() error { return (&controller.DomainReconciler{ @@ -834,7 +824,7 @@ func controllerRegistrations( return (&controller.ChallengeReconciler{ Config: serverConfig, DownstreamCluster: deps.downstreamCluster, - }).SetupWithManager(deps.singletonManager) + }).SetupWithManager(deps.federationManager) }}, } } From 568e7f562717055c542ca43b56d59cd3644f4cfe Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 9 Oct 2026 14:07:23 -0500 Subject: [PATCH 05/25] fix DNS intent replication to federation --- .../gateway_resource_replicator_controller.go | 35 ++++++++-- ...way_resource_replicator_controller_test.go | 69 +++++++++++++++++++ internal/controller/internaldns_controller.go | 1 + 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/internal/controller/gateway_resource_replicator_controller.go b/internal/controller/gateway_resource_replicator_controller.go index 79124668..59ff0123 100644 --- a/internal/controller/gateway_resource_replicator_controller.go +++ b/internal/controller/gateway_resource_replicator_controller.go @@ -73,9 +73,10 @@ type reasonHandler struct { type conditionReasonHandlers map[string]map[string]reasonHandler type replicationResourceConfig struct { - statusGVK *schema.GroupVersionKind - statusTransform statusTransformFunc - conditionHandlers conditionReasonHandlers + statusGVK *schema.GroupVersionKind + statusTransform statusTransformFunc + conditionHandlers conditionReasonHandlers + propagatedAnnotations []string // mirrorStatusToAnnotation, when true, copies the upstream resource's full // .status verbatim into the UpstreamStatusAnnotation on the downstream @@ -179,8 +180,9 @@ func initReplicationResourceConfigs() map[string]replicationResourceConfig { // for the federation control plane to carry the rest of the way. Their status // is NSO's own and must not be overwritten from downstream, and the location // label is what the per-location propagation policy selects on. + networkContextGVK := schema.GroupVersionKind{Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindNetworkContext} presenceGVKs := []schema.GroupVersionKind{ - {Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindNetworkContext}, + networkContextGVK, {Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindSubnet}, } for _, gvk := range presenceGVKs { @@ -192,6 +194,9 @@ func initReplicationResourceConfigs() map[string]replicationResourceConfig { }, } } + contextConfig := configs[gvkKey(networkContextGVK)] + contextConfig.propagatedAnnotations = internalDNSIntentAnnotations() + configs[gvkKey(networkContextGVK)] = contextConfig // The network itself, mirrored onto the hub so the central allocator of a // network's fabric identity can be driven by the network rather than infer @@ -391,6 +396,7 @@ func (r *GatewayResourceReplicatorReconciler) ensureDownstreamResource( } propagateLabels(upstreamObj, downstreamObj, resource.replicationResourceConfig.propagatedLabels) + propagateAnnotations(upstreamObj, downstreamObj, resource.replicationResourceConfig.propagatedAnnotations) // Mirror the upstream status into an annotation on the downstream // object's metadata. This is part of the same CreateOrUpdate Update, so it @@ -459,6 +465,27 @@ func propagateLabels(upstreamObj, downstreamObj *unstructured.Unstructured, name downstreamObj.SetLabels(downstreamLabels) } +func propagateAnnotations(upstreamObj, downstreamObj *unstructured.Unstructured, names []string) { + if len(names) == 0 { + return + } + + upstreamAnnotations := upstreamObj.GetAnnotations() + downstreamAnnotations := downstreamObj.GetAnnotations() + if downstreamAnnotations == nil { + downstreamAnnotations = map[string]string{} + } + + for _, name := range names { + if value, ok := upstreamAnnotations[name]; ok { + downstreamAnnotations[name] = value + } else { + delete(downstreamAnnotations, name) + } + } + downstreamObj.SetAnnotations(downstreamAnnotations) +} + // setUpstreamStatusAnnotation copies the upstream resource's full .status // verbatim into the UpstreamStatusAnnotation on the downstream object's // metadata. It is the Karmada-friendly replacement for mirroring the status diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index 727a5d78..d538b0ac 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -1070,3 +1070,72 @@ func TestNetworkIsReplicatedToTheHubWithoutBeingPlaced(t *testing.T) { assert.False(t, cfg.mirrorStatusToAnnotation, "nothing downstream reads a Network's status") } + +func TestNetworkContextReplicationCarriesOnlyDNSIntentAnnotations(t *testing.T) { + configs := initReplicationResourceConfigs() + gvk := schema.GroupVersionKind{ + Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindNetworkContext, + } + cfg := configs[gvkKey(gvk)] + assert.ElementsMatch(t, internalDNSIntentAnnotations(), cfg.propagatedAnnotations) + + upstream := &unstructured.Unstructured{} + upstream.SetAnnotations(map[string]string{ + InternalDNSContextNameAnnotation: "resolver-a", + InternalDNSSequenceAnnotation: "7", + "example.test/untrusted": "discard", + }) + downstream := &unstructured.Unstructured{} + downstream.SetAnnotations(map[string]string{ + InternalDNSContextNameAnnotation: "stale", + InternalDNSAccessUIDAnnotation: "removed-upstream", + "karmada.io/managed": "preserve", + }) + + propagateAnnotations(upstream, downstream, cfg.propagatedAnnotations) + assert.Equal(t, "resolver-a", downstream.GetAnnotations()[InternalDNSContextNameAnnotation]) + assert.Equal(t, "7", downstream.GetAnnotations()[InternalDNSSequenceAnnotation]) + assert.NotContains(t, downstream.GetAnnotations(), InternalDNSAccessUIDAnnotation) + assert.NotContains(t, downstream.GetAnnotations(), "example.test/untrusted") + assert.Equal(t, "preserve", downstream.GetAnnotations()["karmada.io/managed"]) +} + +func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { + gvk := schema.GroupVersionKind{ + Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindNetworkContext, + } + cfg := initReplicationResourceConfigs()[gvkKey(gvk)] + upstream := &unstructured.Unstructured{} + upstream.SetAnnotations(map[string]string{ + InternalDNSRegionAnnotation: "us-central1", + InternalDNSContextNameAnnotation: "resolver-a", + InternalDNSContextUIDAnnotation: "context-uid", + InternalDNSAccessNameAnnotation: "access-a", + InternalDNSAccessUIDAnnotation: "access-uid", + InternalDNSWriterEpochAnnotation: "3", + InternalDNSSequenceAnnotation: "7", + InternalDNSValidUntilAnnotation: "2026-10-09T18:00:00Z", + InternalDNSServiceDestinationAnnotation: "fd70:100::1", + InternalDNSSuffixAnnotation: "tenant.managed.internal", + }) + downstream := &unstructured.Unstructured{} + propagateAnnotations(upstream, downstream, cfg.propagatedAnnotations) + + annotations := downstream.GetAnnotations() + assert.Equal(t, "us-central1", annotations[InternalDNSRegionAnnotation]) + assert.Equal(t, "fd70:100::1", annotations[InternalDNSServiceDestinationAnnotation]) + settings, err := internalDNSSettingsFromAnnotations(annotations, "fd53::53") + assert.NoError(t, err) + if settings == nil { + t.Fatal("replicated DNS intent did not produce resolver settings") + } + assert.Equal(t, "resolver-a", settings.ContextRef.Name) + assert.Equal(t, types.UID("context-uid"), settings.ContextRef.UID) + assert.Equal(t, "access-a", settings.AccessBindingRef.Name) + assert.Equal(t, types.UID("access-uid"), settings.AccessBindingRef.UID) + assert.Equal(t, int64(3), settings.WriterEpoch) + assert.Equal(t, int64(7), settings.Sequence) + assert.True(t, settings.ValidUntil.Time.Equal(time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC))) + assert.Equal(t, []string{"fd53::53"}, settings.Nameservers) + assert.Equal(t, []string{"tenant.managed.internal"}, settings.Searches) +} diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index f9be6cab..4256be71 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -744,6 +744,7 @@ func renewalDelay(now time.Time, validUntil time.Time) time.Duration { func internalDNSIntentAnnotations() []string { return []string{ + InternalDNSRegionAnnotation, InternalDNSContextNameAnnotation, InternalDNSContextUIDAnnotation, InternalDNSAccessNameAnnotation, From 5e50e70c121e22d4b33b16ccaf07256565b15dfe Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Sun, 11 Oct 2026 14:07:06 -0500 Subject: [PATCH 06/25] Request the network default DNS domain from DNS --- internal/controller/internaldns_controller.go | 24 +++++++++++++++++-- .../controller/internaldns_controller_test.go | 10 +++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 4256be71..060c33f2 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -54,6 +54,7 @@ const ( internalDNSRegionTopologyKey = "topology.datum.net/region" internalDNSRenewBefore = 45 * time.Second internalDNSRetryInterval = 10 * time.Second + internalDNSDefaultDomain = "datum.internal" ) var ( @@ -189,7 +190,7 @@ func (r *InternalDNSProjectReconciler) reconcile( suffix, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "suffix") zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") zoneUID, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "uid") - if dnsContext.GetUID() == "" || epoch < 1 || suffix == "" || zoneName == "" || zoneUID == "" { + if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || zoneUID == "" || !resolverContextReady(dnsContext) { return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) } @@ -266,6 +267,11 @@ func (r *InternalDNSProjectReconciler) ensureContext( if ownedNetwork != string(network.UID) || existingConsumer != consumerID { return nil, fmt.Errorf("DNSResolverContext %s/%s belongs to another network lifetime", network.Namespace, name) } + domain, _, _ := unstructured.NestedString(current.Object, "spec", "managedNamespace", "domainSuffix") + enabled, _, _ := unstructured.NestedBool(current.Object, "spec", "managedNamespace", "enabled") + if domain != internalDNSDefaultDomain || !enabled { + return nil, fmt.Errorf("DNSResolverContext %s/%s does not request the network default domain", network.Namespace, name) + } return current, nil } @@ -280,7 +286,7 @@ func (r *InternalDNSProjectReconciler) ensureContext( }}) desired.Object["spec"] = map[string]any{ "consumerID": consumerID, - "managedNamespace": map[string]any{"enabled": true}, + "managedNamespace": map[string]any{"enabled": true, "domainSuffix": internalDNSDefaultDomain}, } if err := cl.Create(ctx, desired); err != nil { return nil, err @@ -288,6 +294,20 @@ func (r *InternalDNSProjectReconciler) ensureContext( return desired, nil } +func resolverContextReady(obj *unstructured.Unstructured) bool { + if !obj.GetDeletionTimestamp().IsZero() || obj.GetGeneration() < 1 { + return false + } + conditions, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") + for _, raw := range conditions { + condition, _ := raw.(map[string]any) + if condition["type"] == "Ready" && condition["status"] == "True" && condition["observedGeneration"] == obj.GetGeneration() { + return true + } + } + return false +} + func (r *InternalDNSProjectReconciler) ensureAccess( ctx context.Context, cl client.Client, diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index 9f64f51d..fde94aea 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -69,11 +69,19 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(dnsContext), dnsContext); err != nil { t.Fatal(err) } + domain, _, _ := unstructured.NestedString(dnsContext.Object, "spec", "managedNamespace", "domainSuffix") + if domain != internalDNSDefaultDomain { + t.Fatalf("network default domain = %q", domain) + } dnsContext.SetUID("dns-context-uid") + dnsContext.SetGeneration(1) + if err := unstructured.SetNestedSlice(dnsContext.Object, []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(1)}}, "status", "conditions"); err != nil { + t.Fatal(err) + } if err := unstructured.SetNestedField(dnsContext.Object, int64(7), "status", "accessWriterEpoch"); err != nil { t.Fatal(err) } - if err := unstructured.SetNestedField(dnsContext.Object, "blue.internal", "status", "managedNamespace", "suffix"); err != nil { + if err := unstructured.SetNestedField(dnsContext.Object, internalDNSDefaultDomain, "status", "managedNamespace", "suffix"); err != nil { t.Fatal(err) } if err := unstructured.SetNestedMap(dnsContext.Object, map[string]any{ From b78462bc40ac1088d653fb081b03455cc22194bd Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Sun, 11 Oct 2026 14:13:04 -0500 Subject: [PATCH 07/25] Test withdrawal for invalid managed DNS allocation --- .../controller/internaldns_controller_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index fde94aea..640c73f6 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -121,6 +121,41 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { t.Fatal(err) } assertDNSIntent(t, ctx, cl, "first", "access-uid", "1") + originalStatus, _, _ := unstructured.NestedMap(dnsContext.Object, "status") + for _, invalid := range []struct { + path []string + value any + }{ + {path: []string{"status", "managedNamespace", "suffix"}, value: "different.internal"}, + {path: []string{"status", "conditions"}, value: []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(0)}}}, + } { + if err := unstructured.SetNestedField(dnsContext.Object, invalid.value, invalid.path...); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, dnsContext); err != nil { + t.Fatal(err) + } + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + current := &networkingv1alpha.NetworkContext{} + if err := cl.Get(ctx, client.ObjectKeyFromObject(first), current); err != nil { + t.Fatal(err) + } + if current.Annotations[InternalDNSSuffixAnnotation] != "" { + t.Fatalf("invalid DNS allocation remained published: %#v", current.Annotations) + } + if err := unstructured.SetNestedMap(dnsContext.Object, originalStatus, "status"); err != nil { + t.Fatal(err) + } + if err := cl.Update(ctx, dnsContext); err != nil { + t.Fatal(err) + } + if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { + t.Fatal(err) + } + assertDNSIntent(t, ctx, cl, "first", "access-uid", "1") + } second := testNetworkContext("second", "context-2") if err := cl.Create(ctx, second); err != nil { From 8a3d3fe89e2088f8f7d5d584cc05212425ab23ba Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 12 Oct 2026 18:44:09 -0500 Subject: [PATCH 08/25] feat: Keep internal DNS access available through observation outages --- api/v1alpha/internaldns_types.go | 10 +-- api/v1alpha/zz_generated.deepcopy.go | 4 +- .../networking.datumapis.com_httpproxies.yaml | 2 +- ...working.datumapis.com_networkcontexts.yaml | 12 --- ...rking.datumapis.com_networkinterfaces.yaml | 6 -- go.mod | 2 +- go.sum | 2 + internal/config/config.go | 13 +--- internal/config/config_test.go | 12 +-- internal/config/zz_generated.deepcopy.go | 1 - ...way_resource_replicator_controller_test.go | 2 - .../controller/internaldns_cell_controller.go | 62 +++++----------- .../internaldns_cell_controller_test.go | 16 +--- internal/controller/internaldns_controller.go | 73 ++++--------------- .../controller/internaldns_controller_test.go | 36 ++++----- .../networkinterface_projection_test.go | 3 - 16 files changed, 61 insertions(+), 195 deletions(-) diff --git a/api/v1alpha/internaldns_types.go b/api/v1alpha/internaldns_types.go index 6c2d474d..c814b675 100644 --- a/api/v1alpha/internaldns_types.go +++ b/api/v1alpha/internaldns_types.go @@ -3,7 +3,6 @@ package v1alpha import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -62,9 +61,6 @@ type NetworkDNSSettings struct { // +kubebuilder:validation:Minimum=1 Sequence int64 `json:"sequence"` - // ValidUntil is the earliest DNS or network authorization deadline. - ValidUntil metav1.Time `json:"validUntil"` - // Nameservers are resolver addresses reachable from this network. // // +kubebuilder:validation:MinItems=1 @@ -92,9 +88,6 @@ type NetworkDNSAccessStatus struct { // Sequence is the exact ready DNS authorization revision. Sequence int64 `json:"sequence"` - // ValidUntil is the earliest verified network-programming deadline. - ValidUntil metav1.Time `json:"validUntil"` - // ServiceDestination identifies this DNS context inside the shared service. // // +kubebuilder:validation:XValidation:rule="isIP(self)",message="serviceDestination must be an IP address" @@ -107,11 +100,10 @@ const ( NetworkContextDNSReadyReasonReady = "Ready" NetworkContextDNSReadyReasonDisabled = "Disabled" NetworkContextDNSReadyReasonPending = "Pending" - NetworkContextDNSReadyReasonAuthorizationExpired = "AuthorizationExpired" + NetworkContextDNSReadyReasonAccessAbsent = "AccessAbsent" NetworkContextDNSReadyReasonNetworkPathNotReady = "NetworkPathNotReady" NetworkContextDNSReadyReasonNetworkIdentityAbsent = "NetworkIdentityAbsent" NetworkInterfaceDNSConfiguredReasonApplied = "Applied" - NetworkInterfaceDNSConfiguredReasonExpired = "Expired" NetworkInterfaceDNSConfiguredReasonUnsupportedMultipleContexts = "UnsupportedMultipleContexts" ) diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index 48b84b4a..f63c335c 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -1298,7 +1298,7 @@ func (in *NetworkContextStatus) DeepCopyInto(out *NetworkContextStatus) { if in.DNSAccess != nil { in, out := &in.DNSAccess, &out.DNSAccess *out = new(NetworkDNSAccessStatus) - (*in).DeepCopyInto(*out) + **out = **in } } @@ -1317,7 +1317,6 @@ func (in *NetworkDNSAccessStatus) DeepCopyInto(out *NetworkDNSAccessStatus) { *out = *in out.ContextRef = in.ContextRef out.AccessBindingRef = in.AccessBindingRef - in.ValidUntil.DeepCopyInto(&out.ValidUntil) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSAccessStatus. @@ -1365,7 +1364,6 @@ func (in *NetworkDNSSettings) DeepCopyInto(out *NetworkDNSSettings) { *out = *in out.ContextRef = in.ContextRef out.AccessBindingRef = in.AccessBindingRef - in.ValidUntil.DeepCopyInto(&out.ValidUntil) if in.Nameservers != nil { in, out := &in.Nameservers, &out.Nameservers *out = make([]string, len(*in)) diff --git a/config/crd/bases/networking.datumapis.com_httpproxies.yaml b/config/crd/bases/networking.datumapis.com_httpproxies.yaml index 37691199..a9260823 100644 --- a/config/crd/bases/networking.datumapis.com_httpproxies.yaml +++ b/config/crd/bases/networking.datumapis.com_httpproxies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.16.4 name: httpproxies.networking.datumapis.com spec: group: networking.datumapis.com diff --git a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml index c9c9731b..409bb2a4 100644 --- a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml +++ b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml @@ -121,11 +121,6 @@ spec: format: int64 minimum: 1 type: integer - validUntil: - description: ValidUntil is the earliest DNS or network authorization - deadline. - format: date-time - type: string writerEpoch: description: WriterEpoch fences a previous access writer after ownership changes. @@ -137,7 +132,6 @@ spec: - contextRef - nameservers - sequence - - validUntil - writerEpoch type: object ipFamilies: @@ -314,11 +308,6 @@ spec: x-kubernetes-validations: - message: serviceDestination must be an IP address rule: isIP(self) - validUntil: - description: ValidUntil is the earliest verified network-programming - deadline. - format: date-time - type: string writerEpoch: description: WriterEpoch is the DNS authorization writer fenced by the proof. @@ -329,7 +318,6 @@ spec: - contextRef - sequence - serviceDestination - - validUntil - writerEpoch type: object ipam: diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml index 3d84a30a..d86eb4bd 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml @@ -244,11 +244,6 @@ spec: format: int64 minimum: 1 type: integer - validUntil: - description: ValidUntil is the earliest DNS or network authorization - deadline. - format: date-time - type: string writerEpoch: description: WriterEpoch fences a previous access writer after ownership changes. @@ -260,7 +255,6 @@ spec: - contextRef - nameservers - sequence - - validUntil - writerEpoch type: object externalAddresses: diff --git a/go.mod b/go.mod index 9a6f17f6..a4582f94 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/stretchr/testify v1.11.1 go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c go.datum.net/datumctl v0.18.1 - go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789 + go.datum.net/network v0.4.1-0.20261012232803-f3b1d90d0806 go.miloapis.com/activity v0.7.1 go.miloapis.com/dns-operator v0.5.1 go.miloapis.com/locations v0.0.1 diff --git a/go.sum b/go.sum index 8396c753..e152e5e4 100644 --- a/go.sum +++ b/go.sum @@ -688,6 +688,8 @@ go.datum.net/datumctl v0.18.1 h1:MP83cRkJ94aq4lYyOm5dSLU60JNnmR486LIeZnaCw8s= go.datum.net/datumctl v0.18.1/go.mod h1:0L0BBgFfud8ZuV9sbsSmeEBUPoK2q9g4boU5yczF92o= go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789 h1:dPm2P7Ao4yRhuZe67WWXHohEo6IJZgT8lavJRn6hE4Y= go.datum.net/network v0.3.1-0.20261009013235-5e00bde12789/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU= +go.datum.net/network v0.4.1-0.20261012232803-f3b1d90d0806 h1:9/1ysKLyPQqp5fA5uwV92BtELJfhhizmIwVSmp4EOYQ= +go.datum.net/network v0.4.1-0.20261012232803-f3b1d90d0806/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= diff --git a/internal/config/config.go b/internal/config/config.go index 098c93c1..e574b8e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,9 +164,6 @@ type InternalDNSConfig struct { // ServicePrefix allocates a stable service-side destination per DNS context. ServicePrefix string `json:"servicePrefix,omitempty"` - // AccessLeaseDuration bounds each DNS access authorization. - AccessLeaseDuration metav1.Duration `json:"accessLeaseDuration,omitempty"` - // ProducerAttachmentSelector selects shared DNS service attachments. ProducerAttachmentSelector map[string]string `json:"producerAttachmentSelector,omitempty"` } @@ -178,9 +175,7 @@ func SetDefaults_InternalDNSConfig(obj *InternalDNSConfig) { if obj.ServicePrefix == "" { obj.ServicePrefix = "fd70:100::/64" } - if obj.AccessLeaseDuration.Duration == 0 { - obj.AccessLeaseDuration = metav1.Duration{Duration: 2 * time.Minute} - } + } func (c *InternalDNSConfig) validate() error { @@ -195,12 +190,6 @@ func (c *InternalDNSConfig) validate() error { if err != nil || !prefix.Addr().Is6() || prefix.Bits() > 64 { return fmt.Errorf("servicePrefix must be an IPv6 prefix of /64 or larger, got %q", c.ServicePrefix) } - if c.AccessLeaseDuration.Duration <= 0 { - return errors.New("accessLeaseDuration must be positive") - } - if c.AccessLeaseDuration.Duration > 2*time.Minute { - return errors.New("accessLeaseDuration must not exceed Galactic's two minute authorization limit") - } if len(c.ProducerAttachmentSelector) == 0 { return errors.New("producerAttachmentSelector is required") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7b7999df..01853c33 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,9 +5,6 @@ import ( "path/filepath" "strings" "testing" - "time" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestInternalDNSDefaultsRemainDisabled(t *testing.T) { @@ -16,8 +13,7 @@ func TestInternalDNSDefaultsRemainDisabled(t *testing.T) { if cfg.InternalDNS.Enabled { t.Fatal("internal DNS must require an explicit alpha feature gate") } - if cfg.InternalDNS.Frontend != "fd53::53" || cfg.InternalDNS.ServicePrefix != "fd70:100::/64" || - cfg.InternalDNS.AccessLeaseDuration.Duration != 2*time.Minute { + if cfg.InternalDNS.Frontend != "fd53::53" || cfg.InternalDNS.ServicePrefix != "fd70:100::/64" { t.Fatalf("unexpected internal DNS defaults: %#v", cfg.InternalDNS) } } @@ -25,16 +21,12 @@ func TestInternalDNSDefaultsRemainDisabled(t *testing.T) { func TestInternalDNSValidation(t *testing.T) { cfg := InternalDNSConfig{ Enabled: true, Frontend: "fd53::53", ServicePrefix: "fd70:100::/64", - AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}, ProducerAttachmentSelector: map[string]string{"service.datum.net/internal-dns": "true"}, } if err := cfg.validate(); err != nil { t.Fatalf("valid config rejected: %v", err) } - cfg.AccessLeaseDuration = metav1.Duration{Duration: 2*time.Minute + time.Second} - if err := cfg.validate(); err == nil { - t.Fatal("authorization beyond Galactic's accepted deadline was allowed") - } + } func TestNetworkServicesOperator_Validate_IrohDisabled(t *testing.T) { diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go index f8231c2f..4f97c7ac 100644 --- a/internal/config/zz_generated.deepcopy.go +++ b/internal/config/zz_generated.deepcopy.go @@ -552,7 +552,6 @@ func (in *IPAMConfig) DeepCopy() *IPAMConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InternalDNSConfig) DeepCopyInto(out *InternalDNSConfig) { *out = *in - out.AccessLeaseDuration = in.AccessLeaseDuration if in.ProducerAttachmentSelector != nil { in, out := &in.ProducerAttachmentSelector, &out.ProducerAttachmentSelector *out = make(map[string]string, len(*in)) diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index d538b0ac..b0016f2a 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -1114,7 +1114,6 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { InternalDNSAccessUIDAnnotation: "access-uid", InternalDNSWriterEpochAnnotation: "3", InternalDNSSequenceAnnotation: "7", - InternalDNSValidUntilAnnotation: "2026-10-09T18:00:00Z", InternalDNSServiceDestinationAnnotation: "fd70:100::1", InternalDNSSuffixAnnotation: "tenant.managed.internal", }) @@ -1135,7 +1134,6 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { assert.Equal(t, types.UID("access-uid"), settings.AccessBindingRef.UID) assert.Equal(t, int64(3), settings.WriterEpoch) assert.Equal(t, int64(7), settings.Sequence) - assert.True(t, settings.ValidUntil.Time.Equal(time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC))) assert.Equal(t, []string{"fd53::53"}, settings.Nameservers) assert.Equal(t, []string{"tenant.managed.internal"}, settings.Searches) } diff --git a/internal/controller/internaldns_cell_controller.go b/internal/controller/internaldns_cell_controller.go index 97388bc7..7fb9c781 100644 --- a/internal/controller/internaldns_cell_controller.go +++ b/internal/controller/internaldns_cell_controller.go @@ -28,9 +28,7 @@ import ( ) const ( - internalDNSConsumerVPCLabel = "networking.datumapis.com/internal-dns-vpc-uid" - internalDNSNetworkLease = 90 * time.Second - internalDNSNetworkRenewBefore = 45 * time.Second + internalDNSConsumerVPCLabel = "networking.datumapis.com/internal-dns-vpc-uid" ) type InternalDNSCellReconciler struct { @@ -68,32 +66,25 @@ func (r *InternalDNSCellReconciler) reconcile( settings, err := internalDNSSettingsFromAnnotations(networkContext.Annotations, r.Config.Frontend) serviceDestination := networkContext.Annotations[InternalDNSServiceDestinationAnnotation] region := networkContext.Annotations[InternalDNSRegionAnnotation] - if err != nil || serviceDestination == "" || region == "" || !settings.ValidUntil.After(r.now()) || + if err != nil || serviceDestination == "" || region == "" || !networkContext.DeletionTimestamp.IsZero() { if cleanupErr := r.cleanup(ctx, cl, networkContext); cleanupErr != nil { return ctrl.Result{}, cleanupErr } return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.report(ctx, networkContext, nil, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, - Reason: networkingv1alpha.NetworkContextDNSReadyReasonAuthorizationExpired, - Message: "No current DNS access authorization is available", + Reason: networkingv1alpha.NetworkContextDNSReadyReasonAccessAbsent, + Message: "No DNS access configuration is available", }) } vpc, consumerAttachments, err := r.consumerVPC(ctx, cl, networkContext) if err != nil { - cleanupErr := r.cleanup(ctx, cl, networkContext) - if reportErr := r.report(ctx, networkContext, nil, metav1.Condition{ - Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.report(ctx, networkContext, nil, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionUnknown, Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkIdentityAbsent, Message: err.Error(), - }); reportErr != nil { - return ctrl.Result{}, errors.Join(cleanupErr, reportErr) - } - if cleanupErr != nil { - return ctrl.Result{}, cleanupErr - } - return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil + }) } for _, attachment := range consumerAttachments { if attachment.Labels[internalDNSConsumerVPCLabel] == string(vpc.UID) { @@ -109,7 +100,6 @@ func (r *InternalDNSCellReconciler) reconcile( } } - deadline := minTime(settings.ValidUntil.Time, r.now().Add(internalDNSNetworkLease)) var attachments cloudv1alpha1.VPCAttachmentList if err := cl.List(ctx, &attachments); err != nil { return ctrl.Result{}, err @@ -119,20 +109,19 @@ func (r *InternalDNSCellReconciler) reconcile( attachmentPointers = append(attachmentPointers, &attachments.Items[i]) } - proofUntil := deadline for _, protocol := range []networkv1alpha1.NetworkRuleProtocol{ networkv1alpha1.NetworkRuleProtocolUDP, networkv1alpha1.NetworkRuleProtocolTCP, } { endpoint, policy, err := r.ensureServicePath( - ctx, cl, networkContext, vpc, protocol, serviceDestination, region, deadline, + ctx, cl, networkContext, vpc, protocol, serviceDestination, region, ) if err != nil { return ctrl.Result{}, err } - until, ready := policyProgrammed(policy, endpoint, attachmentPointers, vpc.Status.VPC, r.now()) + _, ready := policyProgrammed(policy, endpoint, attachmentPointers, vpc.Status.VPC, r.now()) if !ready { if err := r.report(ctx, networkContext, nil, metav1.Condition{ - Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionUnknown, Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkPathNotReady, Message: "Galactic has not acknowledged the current DNS service path on every required node", }); err != nil { @@ -140,7 +129,6 @@ func (r *InternalDNSCellReconciler) reconcile( } return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } - proofUntil = minTime(proofUntil, until) } access := &networkingv1alpha.NetworkDNSAccessStatus{ @@ -148,7 +136,6 @@ func (r *InternalDNSCellReconciler) reconcile( AccessBindingRef: settings.AccessBindingRef, WriterEpoch: settings.WriterEpoch, Sequence: settings.Sequence, - ValidUntil: metav1.NewTime(proofUntil.UTC()), ServiceDestination: serviceDestination, } if err := r.report(ctx, networkContext, access, metav1.Condition{ @@ -158,7 +145,7 @@ func (r *InternalDNSCellReconciler) reconcile( }); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: renewalDelay(r.now(), proofUntil)}, nil + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } func (r *InternalDNSCellReconciler) now() time.Time { @@ -219,7 +206,6 @@ func (r *InternalDNSCellReconciler) ensureServicePath( protocol networkv1alpha1.NetworkRuleProtocol, destination string, region string, - deadline time.Time, ) (*networkv1alpha1.ServiceEndpoint, *networkv1alpha1.ServiceRoutePolicy, error) { name := boundedDNSName(networkContext.Name+"-dns-"+string(protocol), string(networkContext.UID)+"/"+string(protocol)) endpoint := &networkv1alpha1.ServiceEndpoint{ObjectMeta: metav1.ObjectMeta{ @@ -241,12 +227,11 @@ func (r *InternalDNSCellReconciler) ensureServicePath( Namespace: networkContext.Namespace, Name: name, }} _, err = controllerutil.CreateOrUpdate(ctx, cl, policy, func() error { - activeDeadline := networkAuthorizationDeadline(policy, r.now(), deadline) policy.Spec = networkv1alpha1.ServiceRoutePolicySpec{ ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: endpoint.Name}, ConsumerVPCRef: &networkv1alpha1.ServiceRouteVPCReference{Name: vpc.Name, UID: string(vpc.UID)}, Frontend: &networkv1alpha1.ServiceRouteFrontend{Address: r.Config.Frontend}, - Authorization: &networkv1alpha1.ServiceRouteAuthorization{ValidUntil: metav1.NewTime(activeDeadline.UTC())}, + Authorization: &networkv1alpha1.ServiceRouteAuthorization{Persistent: true}, AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{ internalDNSConsumerVPCLabel: string(vpc.UID), }}, @@ -261,21 +246,6 @@ func (r *InternalDNSCellReconciler) ensureServicePath( return endpoint, policy, nil } -func networkAuthorizationDeadline( - policy *networkv1alpha1.ServiceRoutePolicy, - now time.Time, - dnsValidUntil time.Time, -) time.Time { - if policy.Spec.Authorization != nil { - validUntil := policy.Spec.Authorization.ValidUntil.Time - if validUntil.After(now.Add(internalDNSNetworkRenewBefore)) && - !validUntil.After(dnsValidUntil) && !validUntil.After(now.Add(internalDNSNetworkLease)) { - return validUntil - } - } - return minTime(now.Add(internalDNSNetworkLease), dnsValidUntil) -} - func policyProgrammed( policy *networkv1alpha1.ServiceRoutePolicy, endpoint *networkv1alpha1.ServiceEndpoint, @@ -285,14 +255,14 @@ func policyProgrammed( ) (time.Time, bool) { if policy.UID == "" || endpoint.UID == "" || policy.Status.ObservedGeneration != policy.Generation || !apimeta.IsStatusConditionTrue(policy.Status.Conditions, networkv1alpha1.ConditionTypeServiceRouteAccepted) || - policy.Spec.Authorization == nil || !policy.Spec.Authorization.ValidUntil.After(now) { + policy.Spec.Authorization == nil || !policy.Spec.Authorization.Persistent { return time.Time{}, false } plan, err := dnsaccess.ProgrammingPlan(policy, endpoint, attachments, consumerVPCIdentity) if err != nil { return time.Time{}, false } - validUntil := policy.Spec.Authorization.ValidUntil.Time + validUntil := now.Add(time.Minute) for _, node := range plan.Nodes { matched := 0 for _, report := range policy.Status.Nodes { @@ -327,7 +297,9 @@ func (r *InternalDNSCellReconciler) report( return client.IgnoreNotFound(err) } patch := client.MergeFrom(current.DeepCopy()) - current.Status.DNSAccess = access + if access != nil || condition.Status != metav1.ConditionUnknown { + current.Status.DNSAccess = access + } condition.ObservedGeneration = current.Generation apimeta.SetStatusCondition(¤t.Status.Conditions, condition) return hub.Status().Patch(ctx, current, patch) diff --git a/internal/controller/internaldns_cell_controller_test.go b/internal/controller/internaldns_cell_controller_test.go index f667bad1..56f87145 100644 --- a/internal/controller/internaldns_cell_controller_test.go +++ b/internal/controller/internaldns_cell_controller_test.go @@ -46,7 +46,7 @@ func TestPolicyProgrammedRequiresExactCurrentNodeReports(t *testing.T) { ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: endpoint.Name}, ConsumerVPCRef: &networkv1alpha1.ServiceRouteVPCReference{Name: "blue", UID: "vpc-uid"}, Frontend: &networkv1alpha1.ServiceRouteFrontend{Address: "fd53::53"}, - Authorization: &networkv1alpha1.ServiceRouteAuthorization{ValidUntil: deadline}, + Authorization: &networkv1alpha1.ServiceRouteAuthorization{Persistent: true}, AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{ internalDNSConsumerVPCLabel: "vpc-uid", }}, @@ -88,20 +88,6 @@ func TestPolicyProgrammedRequiresExactCurrentNodeReports(t *testing.T) { } } -func TestNetworkAuthorizationDeadlineNeverOutlivesDNS(t *testing.T) { - t.Parallel() - now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) - dnsDeadline := now.Add(time.Minute) - policy := &networkv1alpha1.ServiceRoutePolicy{Spec: networkv1alpha1.ServiceRoutePolicySpec{ - Authorization: &networkv1alpha1.ServiceRouteAuthorization{ - ValidUntil: metav1.NewTime(now.Add(90 * time.Second)), - }, - }} - if got := networkAuthorizationDeadline(policy, now, dnsDeadline); !got.Equal(dnsDeadline) { - t.Fatalf("authorization deadline = %v, want DNS deadline %v", got, dnsDeadline) - } -} - func TestConsumerVPCRejectsMultipleVPCLifetimes(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 060c33f2..88789b03 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -43,7 +43,6 @@ const ( InternalDNSAccessUIDAnnotation = "networking.datumapis.com/internal-dns-access-uid" InternalDNSWriterEpochAnnotation = "networking.datumapis.com/internal-dns-writer-epoch" InternalDNSSequenceAnnotation = "networking.datumapis.com/internal-dns-sequence" - InternalDNSValidUntilAnnotation = "networking.datumapis.com/internal-dns-valid-until" InternalDNSServiceDestinationAnnotation = "networking.datumapis.com/internal-dns-service-destination" InternalDNSSuffixAnnotation = "networking.datumapis.com/internal-dns-suffix" InternalDNSRegionAnnotation = "networking.datumapis.com/internal-dns-region" @@ -52,7 +51,6 @@ const ( internalDNSOwnerProjectUIDAnnotation = "networking.datumapis.com/internal-dns-project-uid" internalDNSRegionTopologyKey = "topology.datum.net/region" - internalDNSRenewBefore = 45 * time.Second internalDNSRetryInterval = 10 * time.Second internalDNSDefaultDomain = "datum.internal" ) @@ -168,7 +166,7 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{}, r.disable(ctx, cl, networkContext, network) } if region == "" { - return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } if controllerutil.AddFinalizer(networkContext, internalDNSFinalizer) { @@ -191,7 +189,7 @@ func (r *InternalDNSProjectReconciler) reconcile( zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") zoneUID, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "uid") if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || zoneUID == "" || !resolverContextReady(dnsContext) { - return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } destination, err := internalDNSDestination(r.Config.ServicePrefix, dnsContext.GetUID()) @@ -203,14 +201,10 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{}, err } if !ready { - return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, r.clearIntent(ctx, cl, networkContext) + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") - validUntil, err := objectTime(binding, "spec", "authorization", "validUntil") - if err != nil { - return ctrl.Result{}, err - } if err := r.publishIntent(ctx, cl, networkContext, map[string]string{ InternalDNSContextNameAnnotation: dnsContext.GetName(), InternalDNSContextUIDAnnotation: string(dnsContext.GetUID()), @@ -218,7 +212,6 @@ func (r *InternalDNSProjectReconciler) reconcile( InternalDNSAccessUIDAnnotation: string(binding.GetUID()), InternalDNSWriterEpochAnnotation: strconv.FormatInt(epoch, 10), InternalDNSSequenceAnnotation: strconv.FormatInt(sequence, 10), - InternalDNSValidUntilAnnotation: validUntil.UTC().Format(time.RFC3339Nano), InternalDNSServiceDestinationAnnotation: destination, InternalDNSSuffixAnnotation: suffix, InternalDNSRegionAnnotation: region, @@ -226,7 +219,7 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: renewalDelay(r.now(), validUntil)}, nil + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } func (r *InternalDNSProjectReconciler) now() time.Time { @@ -324,7 +317,6 @@ func (r *InternalDNSProjectReconciler) ensureAccess( if err != nil && !apierrors.IsNotFound(err) { return nil, false, err } - now := r.now() sequence := int64(1) if err == nil { if current.GetAnnotations()[internalDNSOwnerNetworkUIDAnnotation] != string(network.UID) { @@ -332,14 +324,7 @@ func (r *InternalDNSProjectReconciler) ensureAccess( } if accessBindingMatches(current, dnsContext, region, destination, epoch) { sequence, _, _ = unstructured.NestedInt64(current.Object, "spec", "authorization", "sequence") - validUntil, _ := objectTime(current, "spec", "authorization", "validUntil") - if bindingReady(current, sequence) && validUntil.After(now.Add(internalDNSRenewBefore)) { - return current, true, nil - } - if validUntil.After(now) && !bindingReady(current, sequence) { - return current, false, nil - } - sequence++ + return current, bindingReady(current, sequence), nil } else { currentEpoch, _, _ := unstructured.NestedInt64(current.Object, "spec", "authorization", "writerEpoch") currentContextUID, _, _ := unstructured.NestedString(current.Object, "spec", "contextRef", "uid") @@ -350,7 +335,6 @@ func (r *InternalDNSProjectReconciler) ensureAccess( } } - validUntil := metav1.NewTime(now.Add(r.Config.AccessLeaseDuration.Duration).UTC()) desiredSpec := map[string]any{ "contextRef": map[string]any{"name": dnsContext.GetName(), "uid": string(dnsContext.GetUID())}, "region": region, @@ -360,7 +344,6 @@ func (r *InternalDNSProjectReconciler) ensureAccess( "authorization": map[string]any{ "writerEpoch": epoch, "sequence": sequence, - "validUntil": validUntil.Format(time.RFC3339Nano), }, } annotations := current.GetAnnotations() @@ -578,27 +561,26 @@ func (r *InternalDNSReadinessReconciler) publish( }) } - now := time.Now() - if r.Now != nil { - now = r.Now() - } access := hubContext.Status.DNSAccess ready := apimeta.IsStatusConditionTrue(hubContext.Status.Conditions, networkingv1alpha.NetworkContextDNSReady) - if !ready || !dnsAccessMatches(intent, access) || access == nil || !access.ValidUntil.After(now) { - err := r.updateProject(ctx, cl, projectContext, nil, metav1.Condition{ - Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionFalse, + if !ready || !dnsAccessMatches(intent, access) || access == nil { + settings := projectContext.Spec.DNS + if !equalDNSSettings(settings, intent) { + settings = nil + } + err := r.updateProject(ctx, cl, projectContext, settings, metav1.Condition{ + Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionUnknown, Reason: networkingv1alpha.NetworkContextDNSReadyReasonNetworkPathNotReady, - Message: "The current DNS authorization has no complete, unexpired network programming proof", + Message: "Current network programming health is unavailable", }) return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, err } - intent.ValidUntil = access.ValidUntil err = r.updateProject(ctx, cl, projectContext, intent, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, Message: "DNS authorization and private network programming are current", }) - return ctrl.Result{RequeueAfter: renewalDelay(now, access.ValidUntil.Time)}, err + return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, err } func (r *InternalDNSReadinessReconciler) updateProject( @@ -646,9 +628,8 @@ func internalDNSSettingsFromAnnotations( accessUID := annotations[InternalDNSAccessUIDAnnotation] epoch, epochErr := strconv.ParseInt(annotations[InternalDNSWriterEpochAnnotation], 10, 64) sequence, sequenceErr := strconv.ParseInt(annotations[InternalDNSSequenceAnnotation], 10, 64) - validUntil, timeErr := time.Parse(time.RFC3339Nano, annotations[InternalDNSValidUntilAnnotation]) if contextName == "" || contextUID == "" || accessName == "" || accessUID == "" || - epochErr != nil || sequenceErr != nil || timeErr != nil { + epochErr != nil || sequenceErr != nil { return nil, errors.New("DNS access intent is incomplete") } settings := &networkingv1alpha.NetworkDNSSettings{ @@ -656,7 +637,6 @@ func internalDNSSettingsFromAnnotations( AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: accessName, UID: types.UID(accessUID)}, WriterEpoch: epoch, Sequence: sequence, - ValidUntil: metav1.NewTime(validUntil), Nameservers: []string{frontend}, } if suffix := annotations[InternalDNSSuffixAnnotation]; suffix != "" { @@ -678,7 +658,7 @@ func equalDNSSettings(a, b *networkingv1alpha.NetworkDNSSettings) bool { return a == nil && b == nil } return a.ContextRef == b.ContextRef && a.AccessBindingRef == b.AccessBindingRef && - a.WriterEpoch == b.WriterEpoch && a.Sequence == b.Sequence && a.ValidUntil.Equal(&b.ValidUntil) && + a.WriterEpoch == b.WriterEpoch && a.Sequence == b.Sequence && strings.Join(a.Nameservers, "\x00") == strings.Join(b.Nameservers, "\x00") && strings.Join(a.Searches, "\x00") == strings.Join(b.Searches, "\x00") } @@ -734,18 +714,6 @@ func unstructuredConditionTrue(obj *unstructured.Unstructured, conditionType str return false } -func objectTime(obj *unstructured.Unstructured, fields ...string) (time.Time, error) { - text, found, err := unstructured.NestedString(obj.Object, fields...) - if err != nil || !found { - return time.Time{}, fmt.Errorf("%s has no %s", obj.GetName(), strings.Join(fields, ".")) - } - value, err := time.Parse(time.RFC3339Nano, text) - if err != nil { - return time.Time{}, fmt.Errorf("%s has invalid %s: %w", obj.GetName(), strings.Join(fields, "."), err) - } - return value, nil -} - func newInternalDNSObject(gvk schema.GroupVersionKind, namespace, name string) *unstructured.Unstructured { obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) @@ -754,14 +722,6 @@ func newInternalDNSObject(gvk schema.GroupVersionKind, namespace, name string) * return obj } -func renewalDelay(now time.Time, validUntil time.Time) time.Duration { - delay := validUntil.Sub(now) - internalDNSRenewBefore - if delay < time.Second { - return time.Second - } - return delay -} - func internalDNSIntentAnnotations() []string { return []string{ InternalDNSRegionAnnotation, @@ -771,7 +731,6 @@ func internalDNSIntentAnnotations() []string { InternalDNSAccessUIDAnnotation, InternalDNSWriterEpochAnnotation, InternalDNSSequenceAnnotation, - InternalDNSValidUntilAnnotation, InternalDNSServiceDestinationAnnotation, InternalDNSSuffixAnnotation, } diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index 640c73f6..a4f33b97 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -8,6 +8,7 @@ import ( "time" apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -56,7 +57,6 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { r := &InternalDNSProjectReconciler{ Config: config.InternalDNSConfig{ Frontend: "fd53::53", ServicePrefix: "fd70:100::/64", - AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}, }, Projects: fixedProjectIdentity("project-uid"), Now: func() time.Time { return now }, @@ -142,8 +142,8 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(first), current); err != nil { t.Fatal(err) } - if current.Annotations[InternalDNSSuffixAnnotation] != "" { - t.Fatalf("invalid DNS allocation remained published: %#v", current.Annotations) + if current.Annotations[InternalDNSSuffixAnnotation] != "datum.internal" { + t.Fatalf("unavailable allocation observation withdrew DNS intent: %#v", current.Annotations) } if err := unstructured.SetNestedMap(dnsContext.Object, originalStatus, "status"); err != nil { t.Fatal(err) @@ -186,15 +186,15 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "first"}, current); err != nil { t.Fatal(err) } - if current.Annotations[InternalDNSAccessUIDAnnotation] != "" { - t.Fatalf("expired access remained published: %#v", current.Annotations) + if current.Annotations[InternalDNSAccessUIDAnnotation] != "access-uid" { + t.Fatalf("persistent access was withdrawn: %#v", current.Annotations) } if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { t.Fatal(err) } sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") - if sequence != 2 { - t.Fatalf("renewed sequence = %d, want 2", sequence) + if sequence != 1 { + t.Fatalf("unchanged access sequence = %d, want 1", sequence) } secondCurrent := &networkingv1alpha.NetworkContext{} if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "second"}, secondCurrent); err != nil { @@ -211,7 +211,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { } } -func TestInternalDNSReadinessRequiresExactUnexpiredProof(t *testing.T) { +func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { t.Parallel() ctx := context.Background() now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) @@ -230,7 +230,6 @@ func TestInternalDNSReadinessRequiresExactUnexpiredProof(t *testing.T) { AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, WriterEpoch: 4, Sequence: 9, - ValidUntil: metav1.NewTime(now.Add(time.Minute)), } hubContext.Status.Conditions = []metav1.Condition{{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, @@ -259,11 +258,12 @@ func TestInternalDNSReadinessRequiresExactUnexpiredProof(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { t.Fatal(err) } - if current.Spec.DNS != nil { - t.Fatalf("settings survived mismatched proof: %#v", current.Spec.DNS) + if current.Spec.DNS == nil { + t.Fatal("observation mismatch withdrew applied resolver settings") } hubContext.Status.DNSAccess.Sequence = 9 + hubContext.Status.Conditions[0].Status = metav1.ConditionUnknown r.Now = func() time.Time { return now.Add(2 * time.Minute) } if _, err := r.publish(ctx, cl, current, hubContext); err != nil { t.Fatal(err) @@ -271,8 +271,11 @@ func TestInternalDNSReadinessRequiresExactUnexpiredProof(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { t.Fatal(err) } - if current.Spec.DNS != nil { - t.Fatalf("settings survived proof expiry: %#v", current.Spec.DNS) + if current.Spec.DNS == nil { + t.Fatal("resolver settings expired") + } + if condition := apimeta.FindStatusCondition(current.Status.Conditions, networkingv1alpha.NetworkContextDNSReady); condition == nil || condition.Status != metav1.ConditionUnknown { + t.Fatalf("unavailable observation was not reported: %#v", current.Status.Conditions) } } @@ -295,13 +298,11 @@ func TestAccessBindingWriterEpochChangeStartsNewFence(t *testing.T) { "queryIdentity": map[string]any{"type": "DestinationAddress", "value": "fd70:100::10"}, "authorization": map[string]any{ "writerEpoch": int64(7), "sequence": int64(12), - "validUntil": now.Add(time.Minute).Format(time.RFC3339Nano), }, } cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(binding).Build() r := &InternalDNSProjectReconciler{ - Config: config.InternalDNSConfig{AccessLeaseDuration: metav1.Duration{Duration: 2 * time.Minute}}, - Now: func() time.Time { return now }, + Now: func() time.Time { return now }, } _, ready, err := r.ensureAccess(ctx, cl, network, "project-uid", dnsContext, "us-central", "fd70:100::10", 8) @@ -334,7 +335,7 @@ func testNetworkContext(name string, uid types.UID) *networkingv1alpha.NetworkCo } } -func testDNSIntent(validUntil time.Time) map[string]string { +func testDNSIntent(_ time.Time) map[string]string { return map[string]string{ InternalDNSContextNameAnnotation: "resolver", InternalDNSContextUIDAnnotation: "resolver-uid", @@ -342,7 +343,6 @@ func testDNSIntent(validUntil time.Time) map[string]string { InternalDNSAccessUIDAnnotation: "access-uid", InternalDNSWriterEpochAnnotation: "4", InternalDNSSequenceAnnotation: "9", - InternalDNSValidUntilAnnotation: validUntil.Format(time.RFC3339Nano), InternalDNSSuffixAnnotation: "blue.internal", InternalDNSRegionAnnotation: "us-central", InternalDNSServiceDestinationAnnotation: "fd70:100::10", diff --git a/internal/controller/networkinterface_projection_test.go b/internal/controller/networkinterface_projection_test.go index 54f02af8..a65b3edf 100644 --- a/internal/controller/networkinterface_projection_test.go +++ b/internal/controller/networkinterface_projection_test.go @@ -10,7 +10,6 @@ import ( "strings" "sync" "testing" - "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -361,14 +360,12 @@ func TestInterfaceReachesTheProjectControlPlane(t *testing.T) { } func TestProjectedInterfaceCarriesDNSFence(t *testing.T) { - validUntil := metav1.NewTime(time.Date(2026, time.October, 9, 12, 1, 0, 0, time.UTC)) source := &networkingv1alpha.NetworkInterface{Spec: networkingv1alpha.NetworkInterfaceSpec{ DNS: &networkingv1alpha.NetworkDNSSettings{ ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver", UID: "resolver-uid"}, AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, WriterEpoch: 4, Sequence: 9, - ValidUntil: validUntil, Nameservers: []string{"fd53::53"}, Searches: []string{"blue.internal"}, }, From 6252d27f2a04813467d301530c1d9f76a7cf7b22 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 12 Oct 2026 18:49:57 -0500 Subject: [PATCH 09/25] refactor: Remove unused access expiry clock --- internal/controller/internaldns_controller.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 88789b03..03005706 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -222,13 +222,6 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } -func (r *InternalDNSProjectReconciler) now() time.Time { - if r.Now != nil { - return r.Now() - } - return time.Now() -} - func (r *InternalDNSProjectReconciler) region( ctx context.Context, cl client.Client, From 32adde970e317a04c97959d1944ebc454b4ce4a1 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 12 Oct 2026 19:07:39 -0500 Subject: [PATCH 10/25] docs: Describe persistent interface resolver settings --- api/v1alpha/networkinterface_types.go | 5 ++--- .../bases/networking.datumapis.com_networkinterfaces.yaml | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/api/v1alpha/networkinterface_types.go b/api/v1alpha/networkinterface_types.go index 544d697d..ddc11a45 100644 --- a/api/v1alpha/networkinterface_types.go +++ b/api/v1alpha/networkinterface_types.go @@ -115,7 +115,7 @@ const ( NetworkInterfaceHolderAvailable = "HolderAvailable" // NetworkInterfaceDNSConfigured is reported by the provider after applying - // the current unexpired settings in spec.dns to the workload. + // the current settings in spec.dns to the workload. NetworkInterfaceDNSConfigured = "DNSConfigured" ) @@ -342,8 +342,7 @@ type NetworkInterfaceSpec struct { ReclaimPolicy NetworkInterfaceReclaimPolicy `json:"reclaimPolicy,omitempty"` // dns contains verified resolver settings inherited from the network - // context. Providers remove expired settings and report DNSConfigured after - // applying the current revision. + // context. Providers report DNSConfigured after applying the current revision. // // +kubebuilder:validation:Optional DNS *NetworkDNSSettings `json:"dns,omitempty"` diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml index d86eb4bd..1b74d93d 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml @@ -177,8 +177,7 @@ spec: dns: description: |- dns contains verified resolver settings inherited from the network - context. Providers remove expired settings and report DNSConfigured after - applying the current revision. + context. Providers report DNSConfigured after applying the current revision. properties: accessBindingRef: description: AccessBindingRef identifies the regional DNS authorization From 6d0738587f53ae957de852c32df633b36d2c5486 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 07:59:35 -0500 Subject: [PATCH 11/25] fix: Report managed DNS in interface status --- api/v1alpha/internaldns_types.go | 20 ++-- api/v1alpha/networkinterface_types.go | 9 +- api/v1alpha/zz_generated.deepcopy.go | 35 +++++- ...working.datumapis.com_networkcontexts.yaml | 36 ------ ...rking.datumapis.com_networkinterfaces.yaml | 103 ++++-------------- ...way_resource_replicator_controller_test.go | 6 +- internal/controller/internaldns_controller.go | 23 +--- .../controller/internaldns_controller_test.go | 25 ++--- .../controller/networkinterface_projection.go | 2 +- .../networkinterface_projection_test.go | 20 ++-- .../networkinterfaceclaim_controller.go | 14 ++- .../networkinterfaceclaim_controller_test.go | 49 +++++++++ 12 files changed, 152 insertions(+), 190 deletions(-) diff --git a/api/v1alpha/internaldns_types.go b/api/v1alpha/internaldns_types.go index c814b675..2e601af5 100644 --- a/api/v1alpha/internaldns_types.go +++ b/api/v1alpha/internaldns_types.go @@ -2,10 +2,6 @@ package v1alpha -import ( - "k8s.io/apimachinery/pkg/types" -) - // +kubebuilder:validation:Enum=Managed;Disabled type NetworkDNSMode string @@ -23,20 +19,22 @@ type NetworkDNSPolicy struct { Mode NetworkDNSMode `json:"mode,omitempty"` } -// NetworkDNSObjectReference pins a DNS resource to its API-assigned lifetime. -// +kubebuilder:validation:XValidation:rule="self.uid.size() > 0",message="uid must not be empty" +// NetworkDNSObjectReference identifies a DNS resource by name. type NetworkDNSObjectReference struct { // Name is the resource name in the network's namespace. // // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 Name string `json:"name"` +} - // UID prevents a resource recreated under the same name from inheriting - // resolver access. - // - // +kubebuilder:validation:Required - UID types.UID `json:"uid"` +type NetworkDNSResolverSettings struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=3 + // +kubebuilder:validation:XValidation:rule="self.all(n, isIP(n))",message="nameservers must contain IP addresses" + Nameservers []string `json:"nameservers"` + // +kubebuilder:validation:MaxItems=6 + Searches []string `json:"searches,omitempty"` } // NetworkDNSSettings are verified resolver settings inherited by interfaces. diff --git a/api/v1alpha/networkinterface_types.go b/api/v1alpha/networkinterface_types.go index ddc11a45..7319a3a1 100644 --- a/api/v1alpha/networkinterface_types.go +++ b/api/v1alpha/networkinterface_types.go @@ -340,18 +340,15 @@ type NetworkInterfaceSpec struct { // +kubebuilder:validation:Optional // +kubebuilder:default="Delete" ReclaimPolicy NetworkInterfaceReclaimPolicy `json:"reclaimPolicy,omitempty"` - - // dns contains verified resolver settings inherited from the network - // context. Providers report DNSConfigured after applying the current revision. - // - // +kubebuilder:validation:Optional - DNS *NetworkDNSSettings `json:"dns,omitempty"` } // NetworkInterfaceStatus defines the observed state of NetworkInterface: which // claim holds it, what realizes it on the data plane, and whether programming // has succeeded. type NetworkInterfaceStatus struct { + // DNS contains effective resolver settings inherited from the network. + // +kubebuilder:validation:Optional + DNS *NetworkDNSResolverSettings `json:"dns,omitempty"` // phase reports whether a claim holds the interface. Bound means the claim in // spec.claimRef holds it. Available means it is retained and holding its // addresses with no claim bound. diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index f63c335c..d05bae34 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -1359,6 +1359,31 @@ func (in *NetworkDNSPolicy) DeepCopy() *NetworkDNSPolicy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkDNSResolverSettings) DeepCopyInto(out *NetworkDNSResolverSettings) { + *out = *in + if in.Nameservers != nil { + in, out := &in.Nameservers, &out.Nameservers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Searches != nil { + in, out := &in.Searches, &out.Searches + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkDNSResolverSettings. +func (in *NetworkDNSResolverSettings) DeepCopy() *NetworkDNSResolverSettings { + if in == nil { + return nil + } + out := new(NetworkDNSResolverSettings) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkDNSSettings) DeepCopyInto(out *NetworkDNSSettings) { *out = *in @@ -1723,11 +1748,6 @@ func (in *NetworkInterfaceSpec) DeepCopyInto(out *NetworkInterfaceSpec) { *out = make([]NetworkInterfaceExternalAddress, len(*in)) copy(*out, *in) } - if in.DNS != nil { - in, out := &in.DNS, &out.DNS - *out = new(NetworkDNSSettings) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceSpec. @@ -1743,6 +1763,11 @@ func (in *NetworkInterfaceSpec) DeepCopy() *NetworkInterfaceSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkInterfaceStatus) DeepCopyInto(out *NetworkInterfaceStatus) { *out = *in + if in.DNS != nil { + in, out := &in.DNS, &out.DNS + *out = new(NetworkDNSResolverSettings) + (*in).DeepCopyInto(*out) + } if in.NetworkContextRef != nil { in, out := &in.NetworkContextRef, &out.NetworkContextRef *out = new(LocalNetworkContextRef) diff --git a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml index 409bb2a4..21815fff 100644 --- a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml +++ b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml @@ -65,18 +65,9 @@ spec: description: Name is the resource name in the network's namespace. minLength: 1 type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string required: - name - - uid type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 contextRef: description: ContextRef identifies the DNS serving and namespace lifetime. @@ -85,18 +76,9 @@ spec: description: Name is the resource name in the network's namespace. minLength: 1 type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string required: - name - - uid type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 nameservers: description: Nameservers are resolver addresses reachable from this network. @@ -265,18 +247,9 @@ spec: description: Name is the resource name in the network's namespace. minLength: 1 type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string required: - name - - uid type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 contextRef: description: ContextRef identifies the DNS context used for the proof. @@ -285,18 +258,9 @@ spec: description: Name is the resource name in the network's namespace. minLength: 1 type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string required: - name - - uid type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 sequence: description: Sequence is the exact ready DNS authorization revision. format: int64 diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml index 1b74d93d..4973ac3a 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml @@ -174,88 +174,6 @@ spec: required: - name type: object - dns: - description: |- - dns contains verified resolver settings inherited from the network - context. Providers report DNSConfigured after applying the current revision. - properties: - accessBindingRef: - description: AccessBindingRef identifies the regional DNS authorization - lifetime. - properties: - name: - description: Name is the resource name in the network's namespace. - minLength: 1 - type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string - required: - - name - - uid - type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 - contextRef: - description: ContextRef identifies the DNS serving and namespace - lifetime. - properties: - name: - description: Name is the resource name in the network's namespace. - minLength: 1 - type: string - uid: - description: |- - UID prevents a resource recreated under the same name from inheriting - resolver access. - type: string - required: - - name - - uid - type: object - x-kubernetes-validations: - - message: uid must not be empty - rule: self.uid.size() > 0 - nameservers: - description: Nameservers are resolver addresses reachable from - this network. - items: - type: string - maxItems: 3 - minItems: 1 - type: array - x-kubernetes-validations: - - message: nameservers must contain IP addresses - rule: self.all(n, isIP(n)) - searches: - description: Searches contains DNS search suffixes allocated to - this network. - items: - type: string - maxItems: 6 - type: array - sequence: - description: Sequence is the exact DNS authorization revision - these settings carry. - format: int64 - minimum: 1 - type: integer - writerEpoch: - description: WriterEpoch fences a previous access writer after - ownership changes. - format: int64 - minimum: 1 - type: integer - required: - - accessBindingRef - - contextRef - - nameservers - - sequence - - writerEpoch - type: object externalAddresses: description: |- externalAddresses are the addresses the interface is reachable at from @@ -465,6 +383,27 @@ spec: - type type: object type: array + dns: + description: DNS contains effective resolver settings inherited from + the network. + properties: + nameservers: + items: + type: string + maxItems: 3 + minItems: 1 + type: array + x-kubernetes-validations: + - message: nameservers must contain IP addresses + rule: self.all(n, isIP(n)) + searches: + items: + type: string + maxItems: 6 + type: array + required: + - nameservers + type: object networkContextRef: description: |- networkContextRef is the network's presence in this location, resolved or diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index b0016f2a..03694477 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -1088,14 +1088,14 @@ func TestNetworkContextReplicationCarriesOnlyDNSIntentAnnotations(t *testing.T) downstream := &unstructured.Unstructured{} downstream.SetAnnotations(map[string]string{ InternalDNSContextNameAnnotation: "stale", - InternalDNSAccessUIDAnnotation: "removed-upstream", + InternalDNSAccessNameAnnotation: "removed-upstream", "karmada.io/managed": "preserve", }) propagateAnnotations(upstream, downstream, cfg.propagatedAnnotations) assert.Equal(t, "resolver-a", downstream.GetAnnotations()[InternalDNSContextNameAnnotation]) assert.Equal(t, "7", downstream.GetAnnotations()[InternalDNSSequenceAnnotation]) - assert.NotContains(t, downstream.GetAnnotations(), InternalDNSAccessUIDAnnotation) + assert.NotContains(t, downstream.GetAnnotations(), InternalDNSAccessNameAnnotation) assert.NotContains(t, downstream.GetAnnotations(), "example.test/untrusted") assert.Equal(t, "preserve", downstream.GetAnnotations()["karmada.io/managed"]) } @@ -1109,9 +1109,7 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { upstream.SetAnnotations(map[string]string{ InternalDNSRegionAnnotation: "us-central1", InternalDNSContextNameAnnotation: "resolver-a", - InternalDNSContextUIDAnnotation: "context-uid", InternalDNSAccessNameAnnotation: "access-a", - InternalDNSAccessUIDAnnotation: "access-uid", InternalDNSWriterEpochAnnotation: "3", InternalDNSSequenceAnnotation: "7", InternalDNSServiceDestinationAnnotation: "fd70:100::1", diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 03005706..8487b281 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -38,9 +38,7 @@ const ( internalDNSFinalizer = "networking.datumapis.com/internal-dns-access" InternalDNSContextNameAnnotation = "networking.datumapis.com/internal-dns-context-name" - InternalDNSContextUIDAnnotation = "networking.datumapis.com/internal-dns-context-uid" InternalDNSAccessNameAnnotation = "networking.datumapis.com/internal-dns-access-name" - InternalDNSAccessUIDAnnotation = "networking.datumapis.com/internal-dns-access-uid" InternalDNSWriterEpochAnnotation = "networking.datumapis.com/internal-dns-writer-epoch" InternalDNSSequenceAnnotation = "networking.datumapis.com/internal-dns-sequence" InternalDNSServiceDestinationAnnotation = "networking.datumapis.com/internal-dns-service-destination" @@ -187,8 +185,7 @@ func (r *InternalDNSProjectReconciler) reconcile( epoch, _, _ := unstructured.NestedInt64(dnsContext.Object, "status", "accessWriterEpoch") suffix, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "suffix") zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") - zoneUID, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "uid") - if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || zoneUID == "" || !resolverContextReady(dnsContext) { + if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || !resolverContextReady(dnsContext) { return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } @@ -207,9 +204,7 @@ func (r *InternalDNSProjectReconciler) reconcile( sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") if err := r.publishIntent(ctx, cl, networkContext, map[string]string{ InternalDNSContextNameAnnotation: dnsContext.GetName(), - InternalDNSContextUIDAnnotation: string(dnsContext.GetUID()), InternalDNSAccessNameAnnotation: binding.GetName(), - InternalDNSAccessUIDAnnotation: string(binding.GetUID()), InternalDNSWriterEpochAnnotation: strconv.FormatInt(epoch, 10), InternalDNSSequenceAnnotation: strconv.FormatInt(sequence, 10), InternalDNSServiceDestinationAnnotation: destination, @@ -345,7 +340,6 @@ func (r *InternalDNSProjectReconciler) ensureAccess( } annotations[internalDNSOwnerNetworkUIDAnnotation] = string(network.UID) annotations[internalDNSOwnerProjectUIDAnnotation] = string(projectUID) - annotations[InternalDNSContextUIDAnnotation] = string(dnsContext.GetUID()) annotations[InternalDNSRegionAnnotation] = region current.SetAnnotations(annotations) current.SetOwnerReferences([]metav1.OwnerReference{{ @@ -556,7 +550,7 @@ func (r *InternalDNSReadinessReconciler) publish( access := hubContext.Status.DNSAccess ready := apimeta.IsStatusConditionTrue(hubContext.Status.Conditions, networkingv1alpha.NetworkContextDNSReady) - if !ready || !dnsAccessMatches(intent, access) || access == nil { + if !ready || !dnsAccessMatches(intent, access) || access == nil || access.ServiceDestination != projectContext.Annotations[InternalDNSServiceDestinationAnnotation] { settings := projectContext.Spec.DNS if !equalDNSSettings(settings, intent) { settings = nil @@ -616,18 +610,16 @@ func internalDNSSettingsFromAnnotations( frontend string, ) (*networkingv1alpha.NetworkDNSSettings, error) { contextName := annotations[InternalDNSContextNameAnnotation] - contextUID := annotations[InternalDNSContextUIDAnnotation] accessName := annotations[InternalDNSAccessNameAnnotation] - accessUID := annotations[InternalDNSAccessUIDAnnotation] epoch, epochErr := strconv.ParseInt(annotations[InternalDNSWriterEpochAnnotation], 10, 64) sequence, sequenceErr := strconv.ParseInt(annotations[InternalDNSSequenceAnnotation], 10, 64) - if contextName == "" || contextUID == "" || accessName == "" || accessUID == "" || + if contextName == "" || accessName == "" || epochErr != nil || sequenceErr != nil { return nil, errors.New("DNS access intent is incomplete") } settings := &networkingv1alpha.NetworkDNSSettings{ - ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: contextName, UID: types.UID(contextUID)}, - AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: accessName, UID: types.UID(accessUID)}, + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: contextName}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: accessName}, WriterEpoch: epoch, Sequence: sequence, Nameservers: []string{frontend}, @@ -692,8 +684,7 @@ func boundedDNSName(prefix, identity string) string { func bindingReady(binding *unstructured.Unstructured, sequence int64) bool { observed, found, _ := unstructured.NestedInt64(binding.Object, "status", "observedSequence") - bindingUID, _, _ := unstructured.NestedString(binding.Object, "status", "bindingRef", "uid") - return found && observed == sequence && bindingUID != "" && unstructuredConditionTrue(binding, "Ready") + return found && observed == sequence && unstructuredConditionTrue(binding, "Ready") } func unstructuredConditionTrue(obj *unstructured.Unstructured, conditionType string) bool { @@ -719,9 +710,7 @@ func internalDNSIntentAnnotations() []string { return []string{ InternalDNSRegionAnnotation, InternalDNSContextNameAnnotation, - InternalDNSContextUIDAnnotation, InternalDNSAccessNameAnnotation, - InternalDNSAccessUIDAnnotation, InternalDNSWriterEpochAnnotation, InternalDNSSequenceAnnotation, InternalDNSServiceDestinationAnnotation, diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index a4f33b97..262c2498 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -85,7 +85,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { t.Fatal(err) } if err := unstructured.SetNestedMap(dnsContext.Object, map[string]any{ - "name": "blue-internal", "uid": "zone-uid", + "name": "blue-internal", }, "status", "managedNamespace", "dnsZoneRef"); err != nil { t.Fatal(err) } @@ -120,7 +120,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "first", "access-uid", "1") + assertDNSIntent(t, ctx, cl, "first", bindingName, "1") originalStatus, _, _ := unstructured.NestedMap(dnsContext.Object, "status") for _, invalid := range []struct { path []string @@ -154,7 +154,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "first", "access-uid", "1") + assertDNSIntent(t, ctx, cl, "first", bindingName, "1") } second := testNetworkContext("second", "context-2") @@ -164,7 +164,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(second)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "second", "access-uid", "1") + assertDNSIntent(t, ctx, cl, "second", bindingName, "1") assertUnstructuredCount(t, ctx, cl, internalDNSContextGVK, "tenant", 1) assertUnstructuredCount(t, ctx, cl, internalDNSAccessGVK, "tenant", 1) firstCurrent := &networkingv1alpha.NetworkContext{} @@ -186,7 +186,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "first"}, current); err != nil { t.Fatal(err) } - if current.Annotations[InternalDNSAccessUIDAnnotation] != "access-uid" { + if current.Annotations[InternalDNSAccessNameAnnotation] != bindingName { t.Fatalf("persistent access was withdrawn: %#v", current.Annotations) } if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { @@ -226,10 +226,11 @@ func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { WithObjects(projectContext).Build() hubContext := projectContext.DeepCopy() hubContext.Status.DNSAccess = &networkingv1alpha.NetworkDNSAccessStatus{ - ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver", UID: "resolver-uid"}, - AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, - WriterEpoch: 4, - Sequence: 9, + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver"}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access"}, + WriterEpoch: 4, + Sequence: 9, + ServiceDestination: projectContext.Annotations[InternalDNSServiceDestinationAnnotation], } hubContext.Status.Conditions = []metav1.Condition{{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, @@ -338,9 +339,7 @@ func testNetworkContext(name string, uid types.UID) *networkingv1alpha.NetworkCo func testDNSIntent(_ time.Time) map[string]string { return map[string]string{ InternalDNSContextNameAnnotation: "resolver", - InternalDNSContextUIDAnnotation: "resolver-uid", InternalDNSAccessNameAnnotation: "access", - InternalDNSAccessUIDAnnotation: "access-uid", InternalDNSWriterEpochAnnotation: "4", InternalDNSSequenceAnnotation: "9", InternalDNSSuffixAnnotation: "blue.internal", @@ -349,13 +348,13 @@ func testDNSIntent(_ time.Time) map[string]string { } } -func assertDNSIntent(t *testing.T, ctx context.Context, cl client.Client, name, uid, sequence string) { +func assertDNSIntent(t *testing.T, ctx context.Context, cl client.Client, name, accessName, sequence string) { t.Helper() current := &networkingv1alpha.NetworkContext{} if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: name}, current); err != nil { t.Fatal(err) } - if current.Annotations[InternalDNSAccessUIDAnnotation] != uid || + if current.Annotations[InternalDNSAccessNameAnnotation] != accessName || current.Annotations[InternalDNSSequenceAnnotation] != sequence { t.Fatalf("unexpected DNS intent: %#v", current.Annotations) } diff --git a/internal/controller/networkinterface_projection.go b/internal/controller/networkinterface_projection.go index e648a9fd..dcad7056 100644 --- a/internal/controller/networkinterface_projection.go +++ b/internal/controller/networkinterface_projection.go @@ -38,9 +38,9 @@ func projectedInterface(source *networkingv1alpha.NetworkInterface, location str Addresses: append([]networkingv1alpha.NetworkInterfaceAddress(nil), source.Spec.Addresses...), ExternalAddresses: append([]networkingv1alpha.NetworkInterfaceExternalAddress(nil), source.Spec.ExternalAddresses...), ReclaimPolicy: source.Spec.ReclaimPolicy, - DNS: source.Spec.DNS.DeepCopy(), }, Status: networkingv1alpha.NetworkInterfaceStatus{ + DNS: source.Status.DNS.DeepCopy(), Phase: source.Status.Phase, Conditions: append([]metav1.Condition(nil), source.Status.Conditions...), }, diff --git a/internal/controller/networkinterface_projection_test.go b/internal/controller/networkinterface_projection_test.go index a65b3edf..8023c0c7 100644 --- a/internal/controller/networkinterface_projection_test.go +++ b/internal/controller/networkinterface_projection_test.go @@ -359,22 +359,18 @@ func TestInterfaceReachesTheProjectControlPlane(t *testing.T) { require.Equal(t, boundInterfaceName, copied.Labels[networkingv1alpha.NetworkInterfaceHolderLabel]) } -func TestProjectedInterfaceCarriesDNSFence(t *testing.T) { - source := &networkingv1alpha.NetworkInterface{Spec: networkingv1alpha.NetworkInterfaceSpec{ - DNS: &networkingv1alpha.NetworkDNSSettings{ - ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver", UID: "resolver-uid"}, - AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access", UID: "access-uid"}, - WriterEpoch: 4, - Sequence: 9, - Nameservers: []string{"fd53::53"}, - Searches: []string{"blue.internal"}, +func TestProjectedInterfaceReportsDNSSettings(t *testing.T) { + source := &networkingv1alpha.NetworkInterface{Status: networkingv1alpha.NetworkInterfaceStatus{ + DNS: &networkingv1alpha.NetworkDNSResolverSettings{ + Nameservers: []string{"fd53::53"}, + Searches: []string{"blue.internal"}, }, }} projection := projectedInterface(source, "ord-1") - require.Equal(t, source.Spec.DNS, projection.Spec.DNS) - projection.Spec.DNS.Nameservers[0] = "fd53::54" - require.Equal(t, "fd53::53", source.Spec.DNS.Nameservers[0], "the projection must own its DNS settings") + require.Equal(t, source.Status.DNS, projection.Status.DNS) + projection.Status.DNS.Nameservers[0] = "fd53::54" + require.Equal(t, "fd53::53", source.Status.DNS.Nameservers[0], "the projection must own its DNS settings") } func TestCopyDropsWhatOnlyMeansSomethingOnTheCell(t *testing.T) { diff --git a/internal/controller/networkinterfaceclaim_controller.go b/internal/controller/networkinterfaceclaim_controller.go index 52e54031..00734295 100644 --- a/internal/controller/networkinterfaceclaim_controller.go +++ b/internal/controller/networkinterfaceclaim_controller.go @@ -1024,9 +1024,17 @@ func (r *NetworkInterfaceClaimReconciler) syncInterface( iface.Spec.MTU = networkContext.Spec.MTU changed = true } - if !apiequality.Semantic.DeepEqual(iface.Spec.DNS, networkContext.Spec.DNS) { - iface.Spec.DNS = networkContext.Spec.DNS.DeepCopy() - changed = true + var dns *networkingv1alpha.NetworkDNSResolverSettings + if settings := networkContext.Spec.DNS; settings != nil { + dns = &networkingv1alpha.NetworkDNSResolverSettings{Nameservers: append([]string(nil), settings.Nameservers...), Searches: append([]string(nil), settings.Searches...)} + } + if !apiequality.Semantic.DeepEqual(iface.Status.DNS, dns) { + base := iface.DeepCopy() + iface.Status.DNS = dns + apimeta.RemoveStatusCondition(&iface.Status.Conditions, networkingv1alpha.NetworkInterfaceDNSConfigured) + if err := cl.Status().Patch(ctx, iface, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return fmt.Errorf("publish interface resolver settings: %w", err) + } } // An interface adopted from before the field existed carries none. if claim.Spec.AttachmentMode != "" && iface.Spec.AttachmentMode != claim.Spec.AttachmentMode { diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go index 6e7a72e8..f2211fc5 100644 --- a/internal/controller/networkinterfaceclaim_controller_test.go +++ b/internal/controller/networkinterfaceclaim_controller_test.go @@ -23,6 +23,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation" @@ -1677,6 +1678,54 @@ func TestNetworkInterfaceClaimTakesMTUFromTheNetworkContext(t *testing.T) { "the context carries the MTU, and the network the cell cannot read carries 1440") } +func TestNetworkInterfaceDNSIsReadOnlyAndFollowsTheNetwork(t *testing.T) { + s := newScenario(t, true, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}) + contextObject, err := s.getNetworkContext() + require.NoError(t, err) + contextObject.Spec.DNS = &networkingv1alpha.NetworkDNSSettings{ + ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver"}, + AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access"}, + WriterEpoch: 1, Sequence: 1, Nameservers: []string{"fd53::53"}, Searches: []string{"datum.internal"}, + } + require.NoError(t, s.client.Update(s.ctx, contextObject)) + claim := s.createClaim("dns-interface", networkingv1alpha.NetworkInterfaceClaimSpec{ + InterfaceName: "eth0", IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyDelete, + }) + s.reconcile(claim) + iface, err := s.getInterface(claim.Name) + require.NoError(t, err) + require.Equal(t, []string{"fd53::53"}, iface.Status.DNS.Nameservers) + require.Equal(t, []string{"datum.internal"}, iface.Status.DNS.Searches) + object := &unstructured.Unstructured{} + object.SetGroupVersionKind(networkingv1alpha.GroupVersion.WithKind("NetworkInterface")) + require.NoError(t, s.client.Get(s.ctx, client.ObjectKeyFromObject(iface), object)) + for _, field := range []string{"spec", "status"} { + require.NoError(t, unstructured.SetNestedMap(object.Object, map[string]any{"nameservers": []any{"fd53::54"}}, field, "dns")) + } + require.NoError(t, s.client.Update(s.ctx, object)) + require.NoError(t, s.client.Get(s.ctx, client.ObjectKeyFromObject(iface), object)) + _, editable, err := unstructured.NestedMap(object.Object, "spec", "dns") + require.NoError(t, err) + require.False(t, editable) + require.NoError(t, s.client.Get(s.ctx, client.ObjectKeyFromObject(iface), iface)) + require.Equal(t, []string{"fd53::53"}, iface.Status.DNS.Nameservers) + apimeta.SetStatusCondition(&iface.Status.Conditions, metav1.Condition{Type: networkingv1alpha.NetworkInterfaceDNSConfigured, Status: metav1.ConditionTrue, Reason: "Applied", LastTransitionTime: metav1.Now()}) + require.NoError(t, s.client.Status().Update(s.ctx, iface)) + contextObject.Spec.DNS.Searches = []string{"apps.internal"} + require.NoError(t, s.client.Update(s.ctx, contextObject)) + s.reconcile(claim) + iface, err = s.getInterface(claim.Name) + require.NoError(t, err) + require.Equal(t, []string{"apps.internal"}, iface.Status.DNS.Searches) + require.Nil(t, apimeta.FindStatusCondition(iface.Status.Conditions, networkingv1alpha.NetworkInterfaceDNSConfigured)) + contextObject.Spec.DNS = nil + require.NoError(t, s.client.Update(s.ctx, contextObject)) + s.reconcile(claim) + iface, err = s.getInterface(claim.Name) + require.NoError(t, err) + require.Nil(t, iface.Status.DNS) +} + // A network that has not reached the location is a different answer from a // consumer naming a network that does not exist. func TestNetworkInterfaceClaimRejectsAMissingNetworkContext(t *testing.T) { From 99e3b4b399a9dcf897dd2ed67626a5ebc8f60a04 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 08:07:42 -0500 Subject: [PATCH 12/25] test: Verify name-only DNS intent replication --- .../controller/gateway_resource_replicator_controller_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index 03694477..5379b11c 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -1127,9 +1127,7 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { t.Fatal("replicated DNS intent did not produce resolver settings") } assert.Equal(t, "resolver-a", settings.ContextRef.Name) - assert.Equal(t, types.UID("context-uid"), settings.ContextRef.UID) assert.Equal(t, "access-a", settings.AccessBindingRef.Name) - assert.Equal(t, types.UID("access-uid"), settings.AccessBindingRef.UID) assert.Equal(t, int64(3), settings.WriterEpoch) assert.Equal(t, int64(7), settings.Sequence) assert.Equal(t, []string{"fd53::53"}, settings.Nameservers) From 6e42011d034ade457228c74534e14db9b2fd2732 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 08:33:55 -0500 Subject: [PATCH 13/25] test: Refresh network DNS update observations --- internal/controller/networkinterfaceclaim_controller_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go index f2211fc5..17d9a8f1 100644 --- a/internal/controller/networkinterfaceclaim_controller_test.go +++ b/internal/controller/networkinterfaceclaim_controller_test.go @@ -1711,6 +1711,8 @@ func TestNetworkInterfaceDNSIsReadOnlyAndFollowsTheNetwork(t *testing.T) { require.Equal(t, []string{"fd53::53"}, iface.Status.DNS.Nameservers) apimeta.SetStatusCondition(&iface.Status.Conditions, metav1.Condition{Type: networkingv1alpha.NetworkInterfaceDNSConfigured, Status: metav1.ConditionTrue, Reason: "Applied", LastTransitionTime: metav1.Now()}) require.NoError(t, s.client.Status().Update(s.ctx, iface)) + contextObject, err = s.getNetworkContext() + require.NoError(t, err) contextObject.Spec.DNS.Searches = []string{"apps.internal"} require.NoError(t, s.client.Update(s.ctx, contextObject)) s.reconcile(claim) @@ -1718,6 +1720,8 @@ func TestNetworkInterfaceDNSIsReadOnlyAndFollowsTheNetwork(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"apps.internal"}, iface.Status.DNS.Searches) require.Nil(t, apimeta.FindStatusCondition(iface.Status.Conditions, networkingv1alpha.NetworkInterfaceDNSConfigured)) + contextObject, err = s.getNetworkContext() + require.NoError(t, err) contextObject.Spec.DNS = nil require.NoError(t, s.client.Update(s.ctx, contextObject)) s.reconcile(claim) From 3c49d235408c30335b51d1949a96605299a5cb33 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 12:29:16 -0500 Subject: [PATCH 14/25] refactor: Version internal DNS access mappings --- .../controller/internaldns_cell_controller.go | 2 +- internal/controller/internaldns_controller.go | 18 +++++++++--------- .../controller/internaldns_controller_test.go | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/controller/internaldns_cell_controller.go b/internal/controller/internaldns_cell_controller.go index 7fb9c781..6355b75b 100644 --- a/internal/controller/internaldns_cell_controller.go +++ b/internal/controller/internaldns_cell_controller.go @@ -141,7 +141,7 @@ func (r *InternalDNSCellReconciler) reconcile( if err := r.report(ctx, networkContext, access, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, - Message: "DNS authorization and Galactic programming are current", + Message: "DNS access version and Galactic programming are current", }); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 8487b281..dcb2b039 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -182,7 +182,7 @@ func (r *InternalDNSProjectReconciler) reconcile( if err != nil { return ctrl.Result{}, err } - epoch, _, _ := unstructured.NestedInt64(dnsContext.Object, "status", "accessWriterEpoch") + epoch, _, _ := unstructured.NestedInt64(dnsContext.Object, "status", "accessEpoch") suffix, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "suffix") zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || !resolverContextReady(dnsContext) { @@ -201,7 +201,7 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") if err := r.publishIntent(ctx, cl, networkContext, map[string]string{ InternalDNSContextNameAnnotation: dnsContext.GetName(), InternalDNSAccessNameAnnotation: binding.GetName(), @@ -311,10 +311,10 @@ func (r *InternalDNSProjectReconciler) ensureAccess( return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s belongs to another network lifetime", network.Namespace, name) } if accessBindingMatches(current, dnsContext, region, destination, epoch) { - sequence, _, _ = unstructured.NestedInt64(current.Object, "spec", "authorization", "sequence") + sequence, _, _ = unstructured.NestedInt64(current.Object, "spec", "version", "sequence") return current, bindingReady(current, sequence), nil } else { - currentEpoch, _, _ := unstructured.NestedInt64(current.Object, "spec", "authorization", "writerEpoch") + currentEpoch, _, _ := unstructured.NestedInt64(current.Object, "spec", "version", "epoch") currentContextUID, _, _ := unstructured.NestedString(current.Object, "spec", "contextRef", "uid") if currentContextUID == string(dnsContext.GetUID()) && currentEpoch > epoch { return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s has writer epoch %d ahead of context epoch %d", @@ -329,9 +329,9 @@ func (r *InternalDNSProjectReconciler) ensureAccess( "queryIdentity": map[string]any{"type": "DestinationAddress", "value": destination}, "port": int64(53), "transports": []any{"UDP", "TCP"}, - "authorization": map[string]any{ - "writerEpoch": epoch, - "sequence": sequence, + "version": map[string]any{ + "epoch": epoch, + "sequence": sequence, }, } annotations := current.GetAnnotations() @@ -374,7 +374,7 @@ func accessBindingMatches( boundRegion, _, _ := unstructured.NestedString(binding.Object, "spec", "region") identityType, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "type") identityValue, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "value") - writerEpoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "writerEpoch") + writerEpoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "epoch") return contextName == dnsContext.GetName() && contextUID == string(dnsContext.GetUID()) && boundRegion == region && identityType == "DestinationAddress" && identityValue == destination && writerEpoch == epoch } @@ -565,7 +565,7 @@ func (r *InternalDNSReadinessReconciler) publish( err = r.updateProject(ctx, cl, projectContext, intent, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, - Message: "DNS authorization and private network programming are current", + Message: "DNS access version and private network programming are current", }) return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, err } diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index 262c2498..7c927142 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -78,7 +78,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := unstructured.SetNestedSlice(dnsContext.Object, []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(1)}}, "status", "conditions"); err != nil { t.Fatal(err) } - if err := unstructured.SetNestedField(dnsContext.Object, int64(7), "status", "accessWriterEpoch"); err != nil { + if err := unstructured.SetNestedField(dnsContext.Object, int64(7), "status", "accessEpoch"); err != nil { t.Fatal(err) } if err := unstructured.SetNestedField(dnsContext.Object, internalDNSDefaultDomain, "status", "managedNamespace", "suffix"); err != nil { @@ -192,7 +192,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { t.Fatal(err) } - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") if sequence != 1 { t.Fatalf("unchanged access sequence = %d, want 1", sequence) } @@ -297,8 +297,8 @@ func TestAccessBindingWriterEpochChangeStartsNewFence(t *testing.T) { "contextRef": map[string]any{"name": "resolver", "uid": "resolver-uid"}, "region": "us-central", "queryIdentity": map[string]any{"type": "DestinationAddress", "value": "fd70:100::10"}, - "authorization": map[string]any{ - "writerEpoch": int64(7), "sequence": int64(12), + "version": map[string]any{ + "epoch": int64(7), "sequence": int64(12), }, } cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(binding).Build() @@ -316,8 +316,8 @@ func TestAccessBindingWriterEpochChangeStartsNewFence(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(binding), binding); err != nil { t.Fatal(err) } - epoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "writerEpoch") - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "authorization", "sequence") + epoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "epoch") + sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") if epoch != 8 || sequence != 1 { t.Fatalf("new fence = epoch %d sequence %d, want epoch 8 sequence 1", epoch, sequence) } From 381045c2f28c52bb051aec99686291f7a4d8ff96 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 13:33:05 -0500 Subject: [PATCH 15/25] feat: Configure private DNS through named mappings Give networks a private DNS destination without managing DNS publication versions. Keep effective resolver settings available during observation outages and verify access against current network programming. Key changes: - Submit the context, region, and service destination to DNS - Keep DNS readiness separate from verified private network paths - Replace access when the DNS context lifetime changes --- api/v1alpha/internaldns_types.go | 20 +--- ...working.datumapis.com_networkcontexts.yaml | 30 +---- ...way_resource_replicator_controller_test.go | 6 - .../controller/internaldns_cell_controller.go | 4 +- internal/controller/internaldns_controller.go | 109 +++++------------- .../controller/internaldns_controller_test.go | 90 ++++++++------- .../networkinterfaceclaim_controller_test.go | 2 +- 7 files changed, 83 insertions(+), 178 deletions(-) diff --git a/api/v1alpha/internaldns_types.go b/api/v1alpha/internaldns_types.go index 2e601af5..1ebb49fa 100644 --- a/api/v1alpha/internaldns_types.go +++ b/api/v1alpha/internaldns_types.go @@ -44,21 +44,11 @@ type NetworkDNSSettings struct { // +kubebuilder:validation:Required ContextRef NetworkDNSObjectReference `json:"contextRef"` - // AccessBindingRef identifies the regional DNS authorization lifetime. + // AccessBindingRef identifies the regional DNS configuration. // // +kubebuilder:validation:Required AccessBindingRef NetworkDNSObjectReference `json:"accessBindingRef"` - // WriterEpoch fences a previous access writer after ownership changes. - // - // +kubebuilder:validation:Minimum=1 - WriterEpoch int64 `json:"writerEpoch"` - - // Sequence is the exact DNS authorization revision these settings carry. - // - // +kubebuilder:validation:Minimum=1 - Sequence int64 `json:"sequence"` - // Nameservers are resolver addresses reachable from this network. // // +kubebuilder:validation:MinItems=1 @@ -77,15 +67,9 @@ type NetworkDNSAccessStatus struct { // ContextRef identifies the DNS context used for the proof. ContextRef NetworkDNSObjectReference `json:"contextRef"` - // AccessBindingRef identifies the DNS authorization used for the proof. + // AccessBindingRef identifies the DNS configuration used for the proof. AccessBindingRef NetworkDNSObjectReference `json:"accessBindingRef"` - // WriterEpoch is the DNS authorization writer fenced by the proof. - WriterEpoch int64 `json:"writerEpoch"` - - // Sequence is the exact ready DNS authorization revision. - Sequence int64 `json:"sequence"` - // ServiceDestination identifies this DNS context inside the shared service. // // +kubebuilder:validation:XValidation:rule="isIP(self)",message="serviceDestination must be an IP address" diff --git a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml index 21815fff..492830bc 100644 --- a/config/crd/bases/networking.datumapis.com_networkcontexts.yaml +++ b/config/crd/bases/networking.datumapis.com_networkcontexts.yaml @@ -58,8 +58,7 @@ spec: have both been verified for this location. properties: accessBindingRef: - description: AccessBindingRef identifies the regional DNS authorization - lifetime. + description: AccessBindingRef identifies the regional DNS configuration. properties: name: description: Name is the resource name in the network's namespace. @@ -97,24 +96,10 @@ spec: type: string maxItems: 6 type: array - sequence: - description: Sequence is the exact DNS authorization revision - these settings carry. - format: int64 - minimum: 1 - type: integer - writerEpoch: - description: WriterEpoch fences a previous access writer after - ownership changes. - format: int64 - minimum: 1 - type: integer required: - accessBindingRef - contextRef - nameservers - - sequence - - writerEpoch type: object ipFamilies: description: |- @@ -240,7 +225,7 @@ spec: only to this exact context, binding revision, and deadline. properties: accessBindingRef: - description: AccessBindingRef identifies the DNS authorization + description: AccessBindingRef identifies the DNS configuration used for the proof. properties: name: @@ -261,10 +246,6 @@ spec: required: - name type: object - sequence: - description: Sequence is the exact ready DNS authorization revision. - format: int64 - type: integer serviceDestination: description: ServiceDestination identifies this DNS context inside the shared service. @@ -272,17 +253,10 @@ spec: x-kubernetes-validations: - message: serviceDestination must be an IP address rule: isIP(self) - writerEpoch: - description: WriterEpoch is the DNS authorization writer fenced - by the proof. - format: int64 - type: integer required: - accessBindingRef - contextRef - - sequence - serviceDestination - - writerEpoch type: object ipam: description: |- diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index 5379b11c..5f37b224 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -1082,7 +1082,6 @@ func TestNetworkContextReplicationCarriesOnlyDNSIntentAnnotations(t *testing.T) upstream := &unstructured.Unstructured{} upstream.SetAnnotations(map[string]string{ InternalDNSContextNameAnnotation: "resolver-a", - InternalDNSSequenceAnnotation: "7", "example.test/untrusted": "discard", }) downstream := &unstructured.Unstructured{} @@ -1094,7 +1093,6 @@ func TestNetworkContextReplicationCarriesOnlyDNSIntentAnnotations(t *testing.T) propagateAnnotations(upstream, downstream, cfg.propagatedAnnotations) assert.Equal(t, "resolver-a", downstream.GetAnnotations()[InternalDNSContextNameAnnotation]) - assert.Equal(t, "7", downstream.GetAnnotations()[InternalDNSSequenceAnnotation]) assert.NotContains(t, downstream.GetAnnotations(), InternalDNSAccessNameAnnotation) assert.NotContains(t, downstream.GetAnnotations(), "example.test/untrusted") assert.Equal(t, "preserve", downstream.GetAnnotations()["karmada.io/managed"]) @@ -1110,8 +1108,6 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { InternalDNSRegionAnnotation: "us-central1", InternalDNSContextNameAnnotation: "resolver-a", InternalDNSAccessNameAnnotation: "access-a", - InternalDNSWriterEpochAnnotation: "3", - InternalDNSSequenceAnnotation: "7", InternalDNSServiceDestinationAnnotation: "fd70:100::1", InternalDNSSuffixAnnotation: "tenant.managed.internal", }) @@ -1128,8 +1124,6 @@ func TestNetworkContextReplicationProducesUsableDNSIntent(t *testing.T) { } assert.Equal(t, "resolver-a", settings.ContextRef.Name) assert.Equal(t, "access-a", settings.AccessBindingRef.Name) - assert.Equal(t, int64(3), settings.WriterEpoch) - assert.Equal(t, int64(7), settings.Sequence) assert.Equal(t, []string{"fd53::53"}, settings.Nameservers) assert.Equal(t, []string{"tenant.managed.internal"}, settings.Searches) } diff --git a/internal/controller/internaldns_cell_controller.go b/internal/controller/internaldns_cell_controller.go index 6355b75b..1c362d66 100644 --- a/internal/controller/internaldns_cell_controller.go +++ b/internal/controller/internaldns_cell_controller.go @@ -134,14 +134,12 @@ func (r *InternalDNSCellReconciler) reconcile( access := &networkingv1alpha.NetworkDNSAccessStatus{ ContextRef: settings.ContextRef, AccessBindingRef: settings.AccessBindingRef, - WriterEpoch: settings.WriterEpoch, - Sequence: settings.Sequence, ServiceDestination: serviceDestination, } if err := r.report(ctx, networkContext, access, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, - Message: "DNS access version and Galactic programming are current", + Message: "DNS configuration and Galactic programming are current", }); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index dcb2b039..7e67521c 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "net/netip" - "strconv" "strings" "time" @@ -39,8 +38,6 @@ const ( InternalDNSContextNameAnnotation = "networking.datumapis.com/internal-dns-context-name" InternalDNSAccessNameAnnotation = "networking.datumapis.com/internal-dns-access-name" - InternalDNSWriterEpochAnnotation = "networking.datumapis.com/internal-dns-writer-epoch" - InternalDNSSequenceAnnotation = "networking.datumapis.com/internal-dns-sequence" InternalDNSServiceDestinationAnnotation = "networking.datumapis.com/internal-dns-service-destination" InternalDNSSuffixAnnotation = "networking.datumapis.com/internal-dns-suffix" InternalDNSRegionAnnotation = "networking.datumapis.com/internal-dns-region" @@ -182,10 +179,9 @@ func (r *InternalDNSProjectReconciler) reconcile( if err != nil { return ctrl.Result{}, err } - epoch, _, _ := unstructured.NestedInt64(dnsContext.Object, "status", "accessEpoch") suffix, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "suffix") zoneName, _, _ := unstructured.NestedString(dnsContext.Object, "status", "managedNamespace", "dnsZoneRef", "name") - if dnsContext.GetUID() == "" || epoch < 1 || suffix != internalDNSDefaultDomain || zoneName == "" || !resolverContextReady(dnsContext) { + if dnsContext.GetUID() == "" || suffix != internalDNSDefaultDomain || zoneName == "" || !resolverContextReady(dnsContext) { return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } @@ -193,7 +189,7 @@ func (r *InternalDNSProjectReconciler) reconcile( if err != nil { return ctrl.Result{}, err } - binding, ready, err := r.ensureAccess(ctx, cl, network, projectUID, dnsContext, region, destination, epoch) + binding, ready, err := r.ensureAccess(ctx, cl, network, projectUID, dnsContext, region, destination) if err != nil { return ctrl.Result{}, err } @@ -201,12 +197,9 @@ func (r *InternalDNSProjectReconciler) reconcile( return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, nil } - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") if err := r.publishIntent(ctx, cl, networkContext, map[string]string{ InternalDNSContextNameAnnotation: dnsContext.GetName(), InternalDNSAccessNameAnnotation: binding.GetName(), - InternalDNSWriterEpochAnnotation: strconv.FormatInt(epoch, 10), - InternalDNSSequenceAnnotation: strconv.FormatInt(sequence, 10), InternalDNSServiceDestinationAnnotation: destination, InternalDNSSuffixAnnotation: suffix, InternalDNSRegionAnnotation: region, @@ -297,7 +290,6 @@ func (r *InternalDNSProjectReconciler) ensureAccess( dnsContext *unstructured.Unstructured, region string, destination string, - epoch int64, ) (*unstructured.Unstructured, bool, error) { name := internalDNSRegionalName(dnsContext.GetName(), region) current := newInternalDNSObject(internalDNSAccessGVK, network.Namespace, name) @@ -305,78 +297,45 @@ func (r *InternalDNSProjectReconciler) ensureAccess( if err != nil && !apierrors.IsNotFound(err) { return nil, false, err } - sequence := int64(1) if err == nil { if current.GetAnnotations()[internalDNSOwnerNetworkUIDAnnotation] != string(network.UID) { return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s belongs to another network lifetime", network.Namespace, name) } - if accessBindingMatches(current, dnsContext, region, destination, epoch) { - sequence, _, _ = unstructured.NestedInt64(current.Object, "spec", "version", "sequence") - return current, bindingReady(current, sequence), nil - } else { - currentEpoch, _, _ := unstructured.NestedInt64(current.Object, "spec", "version", "epoch") - currentContextUID, _, _ := unstructured.NestedString(current.Object, "spec", "contextRef", "uid") - if currentContextUID == string(dnsContext.GetUID()) && currentEpoch > epoch { - return nil, false, fmt.Errorf("DNSResolverAccessBinding %s/%s has writer epoch %d ahead of context epoch %d", - network.Namespace, name, currentEpoch, epoch) - } + if !current.GetDeletionTimestamp().IsZero() { + return current, false, nil } + if accessBindingMatches(current, dnsContext, region, destination) { + return current, bindingReady(current), nil + } + uid, resourceVersion := current.GetUID(), current.GetResourceVersion() + return current, false, client.IgnoreNotFound(cl.Delete(ctx, current, client.Preconditions{UID: &uid, ResourceVersion: &resourceVersion})) } - - desiredSpec := map[string]any{ - "contextRef": map[string]any{"name": dnsContext.GetName(), "uid": string(dnsContext.GetUID())}, - "region": region, - "queryIdentity": map[string]any{"type": "DestinationAddress", "value": destination}, - "port": int64(53), - "transports": []any{"UDP", "TCP"}, - "version": map[string]any{ - "epoch": epoch, - "sequence": sequence, - }, - } - annotations := current.GetAnnotations() - if annotations == nil { - annotations = map[string]string{} - } - annotations[internalDNSOwnerNetworkUIDAnnotation] = string(network.UID) - annotations[internalDNSOwnerProjectUIDAnnotation] = string(projectUID) - annotations[InternalDNSRegionAnnotation] = region - current.SetAnnotations(annotations) + current.Object["spec"] = map[string]any{ + "contextRef": map[string]any{"name": dnsContext.GetName(), "uid": string(dnsContext.GetUID())}, + "region": region, + "destinationAddress": destination, + } + current.SetAnnotations(map[string]string{ + internalDNSOwnerNetworkUIDAnnotation: string(network.UID), + internalDNSOwnerProjectUIDAnnotation: string(projectUID), + InternalDNSRegionAnnotation: region, + }) current.SetOwnerReferences([]metav1.OwnerReference{{ APIVersion: internalDNSContextGVK.GroupVersion().String(), Kind: internalDNSContextGVK.Kind, Name: dnsContext.GetName(), UID: dnsContext.GetUID(), Controller: boolPtr(true), BlockOwnerDeletion: boolPtr(true), }}) - if apierrors.IsNotFound(err) { - current.Object["spec"] = desiredSpec - if err := cl.Create(ctx, current); err != nil { - return nil, false, err - } - return current, false, nil - } - if err := unstructured.SetNestedMap(current.Object, desiredSpec, "spec"); err != nil { - return nil, false, err - } - if err := cl.Update(ctx, current); err != nil { + if err := cl.Create(ctx, current); err != nil { return nil, false, err } return current, false, nil } -func accessBindingMatches( - binding *unstructured.Unstructured, - dnsContext *unstructured.Unstructured, - region string, - destination string, - epoch int64, -) bool { +func accessBindingMatches(binding *unstructured.Unstructured, dnsContext *unstructured.Unstructured, region, destination string) bool { contextName, _, _ := unstructured.NestedString(binding.Object, "spec", "contextRef", "name") contextUID, _, _ := unstructured.NestedString(binding.Object, "spec", "contextRef", "uid") boundRegion, _, _ := unstructured.NestedString(binding.Object, "spec", "region") - identityType, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "type") - identityValue, _, _ := unstructured.NestedString(binding.Object, "spec", "queryIdentity", "value") - writerEpoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "epoch") - return contextName == dnsContext.GetName() && contextUID == string(dnsContext.GetUID()) && - boundRegion == region && identityType == "DestinationAddress" && identityValue == destination && writerEpoch == epoch + boundDestination, _, _ := unstructured.NestedString(binding.Object, "spec", "destinationAddress") + return contextName == dnsContext.GetName() && contextUID == string(dnsContext.GetUID()) && boundRegion == region && boundDestination == destination } func (r *InternalDNSProjectReconciler) disable( @@ -565,7 +524,7 @@ func (r *InternalDNSReadinessReconciler) publish( err = r.updateProject(ctx, cl, projectContext, intent, metav1.Condition{ Type: networkingv1alpha.NetworkContextDNSReady, Status: metav1.ConditionTrue, Reason: networkingv1alpha.NetworkContextDNSReadyReasonReady, - Message: "DNS access version and private network programming are current", + Message: "DNS configuration and private network programming are current", }) return ctrl.Result{RequeueAfter: internalDNSRetryInterval}, err } @@ -611,17 +570,12 @@ func internalDNSSettingsFromAnnotations( ) (*networkingv1alpha.NetworkDNSSettings, error) { contextName := annotations[InternalDNSContextNameAnnotation] accessName := annotations[InternalDNSAccessNameAnnotation] - epoch, epochErr := strconv.ParseInt(annotations[InternalDNSWriterEpochAnnotation], 10, 64) - sequence, sequenceErr := strconv.ParseInt(annotations[InternalDNSSequenceAnnotation], 10, 64) - if contextName == "" || accessName == "" || - epochErr != nil || sequenceErr != nil { + if contextName == "" || accessName == "" { return nil, errors.New("DNS access intent is incomplete") } settings := &networkingv1alpha.NetworkDNSSettings{ ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: contextName}, AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: accessName}, - WriterEpoch: epoch, - Sequence: sequence, Nameservers: []string{frontend}, } if suffix := annotations[InternalDNSSuffixAnnotation]; suffix != "" { @@ -633,9 +587,7 @@ func internalDNSSettingsFromAnnotations( func dnsAccessMatches(settings *networkingv1alpha.NetworkDNSSettings, access *networkingv1alpha.NetworkDNSAccessStatus) bool { return settings != nil && access != nil && settings.ContextRef == access.ContextRef && - settings.AccessBindingRef == access.AccessBindingRef && - settings.WriterEpoch == access.WriterEpoch && - settings.Sequence == access.Sequence + settings.AccessBindingRef == access.AccessBindingRef } func equalDNSSettings(a, b *networkingv1alpha.NetworkDNSSettings) bool { @@ -643,7 +595,6 @@ func equalDNSSettings(a, b *networkingv1alpha.NetworkDNSSettings) bool { return a == nil && b == nil } return a.ContextRef == b.ContextRef && a.AccessBindingRef == b.AccessBindingRef && - a.WriterEpoch == b.WriterEpoch && a.Sequence == b.Sequence && strings.Join(a.Nameservers, "\x00") == strings.Join(b.Nameservers, "\x00") && strings.Join(a.Searches, "\x00") == strings.Join(b.Searches, "\x00") } @@ -682,9 +633,9 @@ func boundedDNSName(prefix, identity string) string { return prefix + "-" + suffix } -func bindingReady(binding *unstructured.Unstructured, sequence int64) bool { - observed, found, _ := unstructured.NestedInt64(binding.Object, "status", "observedSequence") - return found && observed == sequence && unstructuredConditionTrue(binding, "Ready") +func bindingReady(binding *unstructured.Unstructured) bool { + observed, found, _ := unstructured.NestedInt64(binding.Object, "status", "observedGeneration") + return found && observed == binding.GetGeneration() && resolverContextReady(binding) } func unstructuredConditionTrue(obj *unstructured.Unstructured, conditionType string) bool { @@ -711,8 +662,6 @@ func internalDNSIntentAnnotations() []string { InternalDNSRegionAnnotation, InternalDNSContextNameAnnotation, InternalDNSAccessNameAnnotation, - InternalDNSWriterEpochAnnotation, - InternalDNSSequenceAnnotation, InternalDNSServiceDestinationAnnotation, InternalDNSSuffixAnnotation, } diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index 7c927142..c66d3df3 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -78,9 +78,6 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := unstructured.SetNestedSlice(dnsContext.Object, []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(1)}}, "status", "conditions"); err != nil { t.Fatal(err) } - if err := unstructured.SetNestedField(dnsContext.Object, int64(7), "status", "accessEpoch"); err != nil { - t.Fatal(err) - } if err := unstructured.SetNestedField(dnsContext.Object, internalDNSDefaultDomain, "status", "managedNamespace", "suffix"); err != nil { t.Fatal(err) } @@ -102,14 +99,15 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { t.Fatal(err) } binding.SetUID("access-uid") - if err := unstructured.SetNestedField(binding.Object, int64(1), "status", "observedSequence"); err != nil { + binding.SetGeneration(1) + if err := unstructured.SetNestedField(binding.Object, int64(1), "status", "observedGeneration"); err != nil { t.Fatal(err) } if err := unstructured.SetNestedField(binding.Object, "dns-binding-uid", "status", "bindingRef", "uid"); err != nil { t.Fatal(err) } if err := unstructured.SetNestedSlice(binding.Object, []any{map[string]any{ - "type": "Ready", "status": "True", "reason": "Ready", + "type": "Ready", "status": "True", "reason": "Ready", "observedGeneration": int64(1), }}, "status", "conditions"); err != nil { t.Fatal(err) } @@ -120,7 +118,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "first", bindingName, "1") + assertDNSIntent(t, ctx, cl, "first", bindingName) originalStatus, _, _ := unstructured.NestedMap(dnsContext.Object, "status") for _, invalid := range []struct { path []string @@ -154,7 +152,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(first)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "first", bindingName, "1") + assertDNSIntent(t, ctx, cl, "first", bindingName) } second := testNetworkContext("second", "context-2") @@ -164,7 +162,7 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if _, err := r.reconcile(ctx, cl, "project", client.ObjectKeyFromObject(second)); err != nil { t.Fatal(err) } - assertDNSIntent(t, ctx, cl, "second", bindingName, "1") + assertDNSIntent(t, ctx, cl, "second", bindingName) assertUnstructuredCount(t, ctx, cl, internalDNSContextGVK, "tenant", 1) assertUnstructuredCount(t, ctx, cl, internalDNSAccessGVK, "tenant", 1) firstCurrent := &networkingv1alpha.NetworkContext{} @@ -192,9 +190,8 @@ func TestInternalDNSProjectLifecycleFencesAccess(t *testing.T) { if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: bindingName}, binding); err != nil { t.Fatal(err) } - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") - if sequence != 1 { - t.Fatalf("unchanged access sequence = %d, want 1", sequence) + if !bindingReady(binding) { + t.Fatal("unchanged access lost readiness") } secondCurrent := &networkingv1alpha.NetworkContext{} if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: "second"}, secondCurrent); err != nil { @@ -228,8 +225,6 @@ func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { hubContext.Status.DNSAccess = &networkingv1alpha.NetworkDNSAccessStatus{ ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver"}, AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access"}, - WriterEpoch: 4, - Sequence: 9, ServiceDestination: projectContext.Annotations[InternalDNSServiceDestinationAnnotation], } hubContext.Status.Conditions = []metav1.Condition{{ @@ -248,11 +243,11 @@ func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { if err := cl.Get(ctx, client.ObjectKeyFromObject(projectContext), current); err != nil { t.Fatal(err) } - if current.Spec.DNS == nil || current.Spec.DNS.Sequence != 9 { + if current.Spec.DNS == nil || current.Spec.DNS.AccessBindingRef.Name != "access" { t.Fatalf("verified settings were not published: %#v", current.Spec.DNS) } - hubContext.Status.DNSAccess.Sequence = 8 + hubContext.Status.DNSAccess.ServiceDestination = "fd70:100::11" if _, err := r.publish(ctx, cl, current, hubContext); err != nil { t.Fatal(err) } @@ -263,7 +258,7 @@ func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { t.Fatal("observation mismatch withdrew applied resolver settings") } - hubContext.Status.DNSAccess.Sequence = 9 + hubContext.Status.DNSAccess.ServiceDestination = projectContext.Annotations[InternalDNSServiceDestinationAnnotation] hubContext.Status.Conditions[0].Status = metav1.ConditionUnknown r.Now = func() time.Time { return now.Add(2 * time.Minute) } if _, err := r.publish(ctx, cl, current, hubContext); err != nil { @@ -280,46 +275,60 @@ func TestInternalDNSReadinessPreservesAppliedSettings(t *testing.T) { } } -func TestAccessBindingWriterEpochChangeStartsNewFence(t *testing.T) { +func TestRecreatedContextReplacesAccessBinding(t *testing.T) { t.Parallel() ctx := context.Background() - now := time.Date(2026, time.October, 9, 12, 0, 0, 0, time.UTC) scheme := runtime.NewScheme() scheme.AddKnownTypeWithName(internalDNSAccessGVK, &unstructured.Unstructured{}) - scheme.AddKnownTypeWithName(internalDNSAccessGVK.GroupVersion().WithKind("DNSResolverAccessBindingList"), &unstructured.UnstructuredList{}) network := &networkingv1alpha.Network{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "blue", UID: "network-uid"}} dnsContext := newInternalDNSObject(internalDNSContextGVK, "tenant", "resolver") - dnsContext.SetUID("resolver-uid") + dnsContext.SetUID("new-context") binding := newInternalDNSObject(internalDNSAccessGVK, "tenant", internalDNSRegionalName("resolver", "us-central")) binding.SetUID("access-uid") binding.SetAnnotations(map[string]string{internalDNSOwnerNetworkUIDAnnotation: string(network.UID)}) binding.Object["spec"] = map[string]any{ - "contextRef": map[string]any{"name": "resolver", "uid": "resolver-uid"}, - "region": "us-central", - "queryIdentity": map[string]any{"type": "DestinationAddress", "value": "fd70:100::10"}, - "version": map[string]any{ - "epoch": int64(7), "sequence": int64(12), - }, + "contextRef": map[string]any{"name": "resolver", "uid": "old-context"}, + "region": "us-central", "destinationAddress": "fd70:100::10", } cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(binding).Build() - r := &InternalDNSProjectReconciler{ - Now: func() time.Time { return now }, - } - - _, ready, err := r.ensureAccess(ctx, cl, network, "project-uid", dnsContext, "us-central", "fd70:100::10", 8) + r := &InternalDNSProjectReconciler{} + _, ready, err := r.ensureAccess(ctx, cl, network, "project-uid", dnsContext, "us-central", "fd70:100::11") if err != nil { t.Fatal(err) } if ready { - t.Fatal("a binding from the previous writer epoch was accepted") + t.Fatal("replaced access was reported ready") } - if err := cl.Get(ctx, client.ObjectKeyFromObject(binding), binding); err != nil { + if err := cl.Get(ctx, client.ObjectKeyFromObject(binding), binding); !apierrors.IsNotFound(err) { + t.Fatalf("retired binding survived: %v", err) + } + current, ready, err := r.ensureAccess(ctx, cl, network, "project-uid", dnsContext, "us-central", "fd70:100::11") + if err != nil { + t.Fatal(err) + } + if ready || !accessBindingMatches(current, dnsContext, "us-central", "fd70:100::11") { + t.Fatal("new context did not receive a pending access binding") + } +} + +func TestAccessReadinessRequiresCurrentGeneration(t *testing.T) { + binding := newInternalDNSObject(internalDNSAccessGVK, "tenant", "access") + binding.SetGeneration(2) + binding.Object["status"] = map[string]any{"observedGeneration": int64(1), "conditions": []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(1)}}} + if bindingReady(binding) { + t.Fatal("stale observation was accepted") + } + if err := unstructured.SetNestedField(binding.Object, int64(2), "status", "observedGeneration"); err != nil { + t.Fatal(err) + } + if bindingReady(binding) { + t.Fatal("stale condition was accepted") + } + if err := unstructured.SetNestedSlice(binding.Object, []any{map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(2)}}, "status", "conditions"); err != nil { t.Fatal(err) } - epoch, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "epoch") - sequence, _, _ := unstructured.NestedInt64(binding.Object, "spec", "version", "sequence") - if epoch != 8 || sequence != 1 { - t.Fatalf("new fence = epoch %d sequence %d, want epoch 8 sequence 1", epoch, sequence) + if !bindingReady(binding) { + t.Fatal("current observation was rejected") } } @@ -340,22 +349,19 @@ func testDNSIntent(_ time.Time) map[string]string { return map[string]string{ InternalDNSContextNameAnnotation: "resolver", InternalDNSAccessNameAnnotation: "access", - InternalDNSWriterEpochAnnotation: "4", - InternalDNSSequenceAnnotation: "9", InternalDNSSuffixAnnotation: "blue.internal", InternalDNSRegionAnnotation: "us-central", InternalDNSServiceDestinationAnnotation: "fd70:100::10", } } -func assertDNSIntent(t *testing.T, ctx context.Context, cl client.Client, name, accessName, sequence string) { +func assertDNSIntent(t *testing.T, ctx context.Context, cl client.Client, name, accessName string) { t.Helper() current := &networkingv1alpha.NetworkContext{} if err := cl.Get(ctx, client.ObjectKey{Namespace: "tenant", Name: name}, current); err != nil { t.Fatal(err) } - if current.Annotations[InternalDNSAccessNameAnnotation] != accessName || - current.Annotations[InternalDNSSequenceAnnotation] != sequence { + if current.Annotations[InternalDNSAccessNameAnnotation] != accessName { t.Fatalf("unexpected DNS intent: %#v", current.Annotations) } } diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go index 17d9a8f1..76ddb45a 100644 --- a/internal/controller/networkinterfaceclaim_controller_test.go +++ b/internal/controller/networkinterfaceclaim_controller_test.go @@ -1685,7 +1685,7 @@ func TestNetworkInterfaceDNSIsReadOnlyAndFollowsTheNetwork(t *testing.T) { contextObject.Spec.DNS = &networkingv1alpha.NetworkDNSSettings{ ContextRef: networkingv1alpha.NetworkDNSObjectReference{Name: "resolver"}, AccessBindingRef: networkingv1alpha.NetworkDNSObjectReference{Name: "access"}, - WriterEpoch: 1, Sequence: 1, Nameservers: []string{"fd53::53"}, Searches: []string{"datum.internal"}, + Nameservers: []string{"fd53::53"}, Searches: []string{"datum.internal"}, } require.NoError(t, s.client.Update(s.ctx, contextObject)) claim := s.createClaim("dns-interface", networkingv1alpha.NetworkInterfaceClaimSpec{ From 07c7dbdcb393d1eff927e57b0c7a19a34a501c1e Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 13 Oct 2026 13:35:50 -0500 Subject: [PATCH 16/25] refactor: Keep current-generation readiness --- internal/controller/internaldns_controller.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 7e67521c..42516264 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -638,17 +638,6 @@ func bindingReady(binding *unstructured.Unstructured) bool { return found && observed == binding.GetGeneration() && resolverContextReady(binding) } -func unstructuredConditionTrue(obj *unstructured.Unstructured, conditionType string) bool { - conditions, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") - for _, raw := range conditions { - condition, _ := raw.(map[string]any) - if condition["type"] == conditionType && condition["status"] == "True" { - return true - } - } - return false -} - func newInternalDNSObject(gvk schema.GroupVersionKind, namespace, name string) *unstructured.Unstructured { obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) From 41b1a8a9c8ee8a9f2feb649f2d331fe3b0378ffd Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 14 Oct 2026 16:24:56 -0500 Subject: [PATCH 17/25] fix: align internal DNS with project routing --- NOTICE | 1718 +++++++++++++++-- docs/api/networkcontexts.md | 219 +++ docs/api/networkinterfaces.md | 43 + docs/api/networks.md | 37 + internal/controller/internaldns_controller.go | 3 +- 5 files changed, 1829 insertions(+), 191 deletions(-) diff --git a/NOTICE b/NOTICE index 1c8bc8fc..3be824e9 100644 --- a/NOTICE +++ b/NOTICE @@ -3092,7 +3092,7 @@ SOFTWARE. ## github.com/go-openapi/swag * Name: github.com/go-openapi/swag -* Version: v0.25.4 +* Version: v0.26.0 * License: Apache-2.0 ``` @@ -3304,7 +3304,7 @@ SOFTWARE. ## github.com/go-openapi/swag/cmdutils * Name: github.com/go-openapi/swag/cmdutils -* Version: v0.25.4 +* Version: v0.26.0 * License: Apache-2.0 ``` @@ -4788,7 +4788,7 @@ SOFTWARE. ## github.com/go-openapi/swag/netutils * Name: github.com/go-openapi/swag/netutils -* Version: v0.25.4 +* Version: v0.26.0 * License: Apache-2.0 ``` @@ -8902,214 +8902,1552 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## go.datum.net/datumctl/plugin +## go.datum.net/cloud/api/v1alpha1 -* Name: go.datum.net/datumctl/plugin -* Version: v0.18.1 -* License: Apache-2.0 +* Name: go.datum.net/cloud/api/v1alpha1 +* Version: v0.1.1-0.20260918213150-260f797bbc5c +* License: AGPL-3.0 ``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. - 1. Definitions. + Preamble - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as +a menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified and installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim or counterclaim) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +its scope, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted +under this License. You may not convey a covered work if you are a +party to an arrangement with a third party that is in the business of +distributing software, under which you make payment to the third party +based on the extent of your activity of conveying the work, and under +which the third party grants, to any of the parties who would receive +the covered work from you, a discriminatory patent license (a) in +connection with copies of the covered work conveyed by you (or copies +made from those copies), or (b) primarily for and in connection with +specific products or compilations that contain the covered work, unless +you entered into that arrangement, or that patent license was granted +prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail +to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies +a copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty. Each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + +Copyright (C) 2026 Datum Technology, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +``` + +## go.datum.net/datumctl/plugin + +* Name: go.datum.net/datumctl/plugin +* Version: v0.18.1 +* License: Apache-2.0 + +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + +## go.datum.net/network/api/v1alpha1 + +* Name: go.datum.net/network/api/v1alpha1 +* Version: v0.4.1-0.20261012232803-f3b1d90d0806 +* License: AGPL-3.0 + +``` +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is designed to make sure +that code based on our programs remains available under the same +copyleft license. Our primary motivation is that software +distributed under a copyleft license should remain free software +for all users, regardless of where the software is run. + + The GNU General Public License (GPL) was designed to ensure +that software distributed under it remains free software for all +its users. However, the GPL does not address the case where +software is distributed as a service, which is a common practice +in the software industry today. The GNU Affero General Public +License addresses this issue by requiring that the source code +of the software be made available to all users of the software, +regardless of whether the software is distributed as a service +or as a traditional software distribution. + + This license is therefore designed to ensure that software +distributed under it remains free software for all its users, +regardless of whether the software is distributed as a service +or as a traditional software distribution. It also ensures that +the source code of the software is made available to all users +of the software, regardless of whether the software is distributed +as a service or as a traditional software distribution. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the work +under this License, and how to view a copy of this License. If the +interface presents a list of user commands or options, such as a menu, +a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to work with, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or assume +responsibilities for propagated copies, provided that you receive all +covered works in source code form. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + Additional permissions are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of a violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously all your obligations under +this License and any other pertinent obligations, then as a consequence +you may not convey it at all. For example, if you agree to terms that +obligate you to collect a royalty for further conveying from those to +whom you convey the Program, the only way you could satisfy both those +terms and this License would be to refrain entirely from conveying the +Program. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). + 13. Remote Network Interaction; Use with the GNU General Public License. - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. + 14. Revised Versions of this License. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version, or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + 15. Disclaimer of Warranty. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + 16. Limitation of Liability. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. + 17. Interpretation of Sections 15 and 16. - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. + END OF TERMS AND CONDITIONS - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + How to Apply These Terms to Your New Programs - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. - END OF TERMS AND CONDITIONS + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. - APPENDIX: How to apply the Apache License to your work. + + Copyright (C) - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - Copyright [yyyy] [name of copyright owner] + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . - http://www.apache.org/licenses/LICENSE-2.0 +Also add information on how to contact you by electronic and paper mail. - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. ``` @@ -9156,7 +10494,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## go.miloapis.com/locations * Name: go.miloapis.com/locations -* Version: v0.0.0-20260825185141-507ac2cbd48c +* Version: v0.0.1 * License: AGPL-3.0 ``` @@ -13168,7 +14506,7 @@ limitations under the License. ## golang.org/x/crypto/pkcs12 * Name: golang.org/x/crypto/pkcs12 -* Version: v0.54.0 +* Version: v0.55.0 * License: BSD-3-Clause ``` @@ -13279,7 +14617,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/net * Name: golang.org/x/net -* Version: v0.56.0 +* Version: v0.58.0 * License: BSD-3-Clause ``` @@ -13464,7 +14802,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/text * Name: golang.org/x/text -* Version: v0.40.0 +* Version: v0.41.0 * License: BSD-3-Clause ``` @@ -13538,7 +14876,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/tools * Name: golang.org/x/tools -* Version: v0.47.0 +* Version: v0.48.0 * License: BSD-3-Clause ``` @@ -14533,7 +15871,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## k8s.io/api * Name: k8s.io/api -* Version: v0.36.1 +* Version: v0.36.3 * License: Apache-2.0 ``` @@ -14957,7 +16295,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## k8s.io/apimachinery/pkg * Name: k8s.io/apimachinery/pkg -* Version: v0.36.1 +* Version: v0.36.3 * License: Apache-2.0 ``` @@ -15169,7 +16507,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## k8s.io/apimachinery/third_party/forked/golang * Name: k8s.io/apimachinery/third_party/forked/golang -* Version: v0.36.1 +* Version: v0.36.3 * License: BSD-3-Clause ``` @@ -15418,7 +16756,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## k8s.io/client-go * Name: k8s.io/client-go -* Version: v0.36.1 +* Version: v0.36.3 * License: Apache-2.0 ``` @@ -16958,7 +18296,7 @@ SOFTWARE. ## k8s.io/streaming/pkg * Name: k8s.io/streaming/pkg -* Version: v0.36.1 +* Version: v0.36.3 * License: Apache-2.0 ``` @@ -17170,7 +18508,7 @@ SOFTWARE. ## k8s.io/utils * Name: k8s.io/utils -* Version: v0.0.0-20260319190234-28399d86e0b5 +* Version: v0.0.0-20260707023825-cf1189d6abe3 * License: Apache-2.0 ``` @@ -17382,7 +18720,7 @@ SOFTWARE. ## k8s.io/utils/internal/third_party/forked/golang * Name: k8s.io/utils/internal/third_party/forked/golang -* Version: v0.0.0-20260319190234-28399d86e0b5 +* Version: v0.0.0-20260707023825-cf1189d6abe3 * License: BSD-3-Clause ``` @@ -17419,7 +18757,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## k8s.io/utils/third_party/forked/golang/btree * Name: k8s.io/utils/third_party/forked/golang/btree -* Version: v0.0.0-20260319190234-28399d86e0b5 +* Version: v0.0.0-20260707023825-cf1189d6abe3 * License: Apache-2.0 ``` @@ -19145,7 +20483,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## sigs.k8s.io/structured-merge-diff/v6 * Name: sigs.k8s.io/structured-merge-diff/v6 -* Version: v6.3.2 +* Version: v6.3.3 * License: Apache-2.0 ``` diff --git a/docs/api/networkcontexts.md b/docs/api/networkcontexts.md index 835a883f..0989cabb 100644 --- a/docs/api/networkcontexts.md +++ b/docs/api/networkcontexts.md @@ -99,6 +99,14 @@ NetworkContextSpec defines the desired state of NetworkContext The attached network
true + + dns + object + + DNS contains resolver settings whose DNS authorization and private path +have both been verified for this location.
+ + false ipFamilies []enum @@ -191,6 +199,111 @@ The attached network +### NetworkContext.spec.dns +[↩ Parent](#networkcontextspec) + + + +DNS contains resolver settings whose DNS authorization and private path +have both been verified for this location. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessBindingRefobject + AccessBindingRef identifies the regional DNS configuration.
+
true
contextRefobject + ContextRef identifies the DNS serving and namespace lifetime.
+
true
nameservers[]string + Nameservers are resolver addresses reachable from this network.
+
+ Validations:
  • self.all(n, isIP(n)): nameservers must contain IP addresses
  • +
    true
    searches[]string + Searches contains DNS search suffixes allocated to this network.
    +
    false
    + + +### NetworkContext.spec.dns.accessBindingRef +[↩ Parent](#networkcontextspecdns) + + + +AccessBindingRef identifies the regional DNS configuration. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the resource name in the network's namespace.
    +
    true
    + + +### NetworkContext.spec.dns.contextRef +[↩ Parent](#networkcontextspecdns) + + + +ContextRef identifies the DNS serving and namespace lifetime. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the resource name in the network's namespace.
    +
    true
    + + ### NetworkContext.status [↩ Parent](#networkcontext) @@ -214,6 +327,14 @@ NetworkContextStatus defines the observed state of NetworkContext Represents the observations of a network context's current state.
    false + + dnsAccess + object + + DNSAccess identifies the current private path proof. DNSReady=True applies +only to this exact context, binding revision, and deadline.
    + + false ipam object @@ -303,6 +424,104 @@ with respect to the current state of the instance.
    +### NetworkContext.status.dnsAccess +[↩ Parent](#networkcontextstatus) + + + +DNSAccess identifies the current private path proof. DNSReady=True applies +only to this exact context, binding revision, and deadline. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    accessBindingRefobject + AccessBindingRef identifies the DNS configuration used for the proof.
    +
    true
    contextRefobject + ContextRef identifies the DNS context used for the proof.
    +
    true
    serviceDestinationstring + ServiceDestination identifies this DNS context inside the shared service.
    +
    + Validations:
  • isIP(self): serviceDestination must be an IP address
  • +
    true
    + + +### NetworkContext.status.dnsAccess.accessBindingRef +[↩ Parent](#networkcontextstatusdnsaccess) + + + +AccessBindingRef identifies the DNS configuration used for the proof. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the resource name in the network's namespace.
    +
    true
    + + +### NetworkContext.status.dnsAccess.contextRef +[↩ Parent](#networkcontextstatusdnsaccess) + + + +ContextRef identifies the DNS context used for the proof. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the resource name in the network's namespace.
    +
    true
    + + ### NetworkContext.status.ipam [↩ Parent](#networkcontextstatus) diff --git a/docs/api/networkinterfaces.md b/docs/api/networkinterfaces.md index 4df452e1..84c5ee2b 100644 --- a/docs/api/networkinterfaces.md +++ b/docs/api/networkinterfaces.md @@ -418,6 +418,13 @@ HolderAvailable means whatever holds the interface reports itself available to serve, and it is the only one of the four a service reads.
    false + + dns + object + + DNS contains effective resolver settings inherited from the network.
    + + false networkContextRef object @@ -571,6 +578,42 @@ with respect to the current state of the instance.
    +### NetworkInterface.status.dns +[↩ Parent](#networkinterfacestatus) + + + +DNS contains effective resolver settings inherited from the network. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    nameservers[]string +
    +
    + Validations:
  • self.all(n, isIP(n)): nameservers must contain IP addresses
  • +
    true
    searches[]string +
    +
    false
    + + ### NetworkInterface.status.networkContextRef [↩ Parent](#networkinterfacestatus) diff --git a/docs/api/networks.md b/docs/api/networks.md index 1a1c65b8..e79da65e 100644 --- a/docs/api/networks.md +++ b/docs/api/networks.md @@ -92,6 +92,13 @@ NetworkSpec defines the desired state of a Network IPAM settings for the network.
    true + + dns + object + + DNS configures private resolver access for the network.
    + + false ipFamilies []enum @@ -172,6 +179,36 @@ networks created before this rule stay writable.
    +### Network.spec.dns +[↩ Parent](#networkspec) + + + +DNS configures private resolver access for the network. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    modeenum + Mode controls whether network services maintain private resolver access. +An empty value uses Managed when the Internal DNS feature is enabled.
    +
    + Enum: Managed, Disabled
    +
    false
    + + ### Network.status [↩ Parent](#network) diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 42516264..c79d1a27 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -481,10 +481,11 @@ func (r *InternalDNSReadinessReconciler) Reconcile(ctx context.Context, req ctrl } return ctrl.Result{}, err } - projectClient, err := r.Projects.ClientForProject(ctx, routing.project) + projectCluster, err := r.Projects.ClusterForProject(ctx, routing.project) if err != nil { return ctrl.Result{}, err } + projectClient := projectCluster.GetClient() projectContext := &networkingv1alpha.NetworkContext{} key := client.ObjectKey{Namespace: routing.projectNamespace, Name: hubContext.Name} if err := projectClient.Get(ctx, key, projectContext); err != nil { From 79703d2a6cc60c94a1710f81b047415c2196526a Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 10:17:07 -0500 Subject: [PATCH 18/25] fix: select the network federation hub explicitly --- NOTICE | 2 +- go.mod | 2 +- go.sum | 4 ++-- internal/cmd/manager/manager.go | 15 ++++++++++++++- internal/cmd/manager/manager_test.go | 22 ++++++++++++++++++++++ internal/config/config.go | 9 +++++---- internal/config/zz_generated.deepcopy.go | 1 + internal/config/zz_generated.defaults.go | 7 +++++++ internal/networkagent/catalog_test.go | 1 + 9 files changed, 54 insertions(+), 9 deletions(-) diff --git a/NOTICE b/NOTICE index 864787fe..95b6ebb7 100644 --- a/NOTICE +++ b/NOTICE @@ -9358,7 +9358,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## go.datum.net/galactic/api * Name: go.datum.net/galactic/api -* Version: v0.6.2-0.20261020171118-c8eca01eb84f +* Version: v0.6.3-0.20261021151056-bb0b4c057338 * License: AGPL-3.0 ``` diff --git a/go.mod b/go.mod index e5784240..602e3d62 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 go.datum.net/datumctl v0.18.1 - go.datum.net/galactic v0.6.2-0.20261020171118-c8eca01eb84f + go.datum.net/galactic v0.6.3-0.20261021151056-bb0b4c057338 go.miloapis.com/activity v0.7.1 go.miloapis.com/dns-operator v0.5.1 go.miloapis.com/locations v0.0.1 diff --git a/go.sum b/go.sum index f753fe8e..7b7f3381 100644 --- a/go.sum +++ b/go.sum @@ -687,8 +687,8 @@ github.com/zonedb/zonedb v1.0.3544 h1:u5a3xOaI338FclecJ/H6J7fiImVEZ/qZomJnVYIlTe github.com/zonedb/zonedb v1.0.3544/go.mod h1:h9mfHV/S6lboOkltULrbNY52cd7JZo6MbxIiqKMWPLg= go.datum.net/datumctl v0.18.1 h1:MP83cRkJ94aq4lYyOm5dSLU60JNnmR486LIeZnaCw8s= go.datum.net/datumctl v0.18.1/go.mod h1:0L0BBgFfud8ZuV9sbsSmeEBUPoK2q9g4boU5yczF92o= -go.datum.net/galactic v0.6.2-0.20261020171118-c8eca01eb84f h1:IfnnuKo1ZpOWrLs8/RqHyrRBXQfWrqvivDotqpRk2KA= -go.datum.net/galactic v0.6.2-0.20261020171118-c8eca01eb84f/go.mod h1:IJDvHOz3iyXb2eWRxvyH5P5Crudz10mMs4oE3WcMn4Y= +go.datum.net/galactic v0.6.3-0.20261021151056-bb0b4c057338 h1:eK8GYG7Z2D9JThUIDuxBGSG3Ur/Jx9F3AL0rVjhdnuk= +go.datum.net/galactic v0.6.3-0.20261021151056-bb0b4c057338/go.mod h1:IJDvHOz3iyXb2eWRxvyH5P5Crudz10mMs4oE3WcMn4Y= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index c3858179..a8e49971 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" @@ -352,7 +353,12 @@ func NewCommand(build BuildInfo) *cobra.Command { // Hub controllers reconcile NetworkBindings and resources published by // cells. Keep their cache and leader election on the federation API, // alongside the authoritative copies they watch. - federationMgr, err := manager.New(downstreamRestConfig, manager.Options{ + federationConfig, err := federationRESTConfig(serverConfig.Federation, cfg) + if err != nil { + setupLog.Error(err, "unable to load federation kubeconfig") + os.Exit(1) + } + federationMgr, err := manager.New(federationConfig, manager.Options{ Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), @@ -602,6 +608,13 @@ type controllerDeps struct { projectIdentities controller.ProjectIdentityResolver } +func federationRESTConfig(federation config.FederationConfig, deployment *rest.Config) (*rest.Config, error) { + if federation.KubeconfigPath == "" { + return rest.CopyConfig(deployment), nil + } + return federation.RestConfig() +} + func newProjectIdentityResolver(serverConfig config.NetworkServicesOperator) (controller.ProjectIdentityResolver, error) { if !serverConfig.InternalDNS.Enabled { return nil, nil diff --git a/internal/cmd/manager/manager_test.go b/internal/cmd/manager/manager_test.go index 488473b4..eb0eef7b 100644 --- a/internal/cmd/manager/manager_test.go +++ b/internal/cmd/manager/manager_test.go @@ -12,12 +12,34 @@ import ( "strings" "testing" + "k8s.io/client-go/rest" + "go.datum.net/network-services-operator/internal/cmd/cell" "go.datum.net/network-services-operator/internal/config" ) var setupWithManagerPattern = regexp.MustCompile(`func \(\w+ \*(\w+)\) SetupWithManager\(`) +func TestFederationRESTConfig(t *testing.T) { + deployment := &rest.Config{Host: "https://deployment.example"} + got, err := federationRESTConfig(config.FederationConfig{}, deployment) + if err != nil || got.Host != deployment.Host || got == deployment { + t.Fatalf("deployment hub configuration: got %v, error %v", got, err) + } + kubeconfig := filepath.Join(t.TempDir(), "hub.yaml") + data := []byte("apiVersion: v1\nkind: Config\nclusters:\n- name: hub\n cluster:\n server: https://hub.example\ncontexts:\n- name: hub\n context:\n cluster: hub\ncurrent-context: hub\n") + if err := os.WriteFile(kubeconfig, data, 0600); err != nil { + t.Fatal(err) + } + got, err = federationRESTConfig(config.FederationConfig{KubeconfigPath: kubeconfig}, deployment) + if err != nil || got.Host != "https://hub.example" { + t.Fatalf("explicit hub configuration: got %v, error %v", got, err) + } + if _, err := federationRESTConfig(config.FederationConfig{KubeconfigPath: kubeconfig + ".missing"}, deployment); err == nil { + t.Fatal("missing federation kubeconfig was accepted") + } +} + var reconcilerControllerNames = map[string]string{ "ChallengeReconciler": "challenge", "ConnectorAdvertisementReconciler": "connectoradvertisement", diff --git a/internal/config/config.go b/internal/config/config.go index 1883e655..e5a4af51 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,10 @@ type NetworkServicesOperator struct { DownstreamResourceManagement DownstreamResourceManagementConfig `json:"downstreamResourceManagement"` + // Federation selects the hub for network bindings and cell observations. + // An empty kubeconfigPath uses the deployment control plane. + Federation FederationConfig `json:"federation,omitempty"` + // Redis provides shared Redis connection settings. Redis RedisConfig `json:"redis"` @@ -277,12 +281,9 @@ func (c *IPAMConfig) RestConfig() (*rest.Config, error) { // +k8s:deepcopy-gen=true -// FederationConfig names the federation hub a cell publishes to. It is the only -// plane a cell and the control planes serving projects both reach. +// FederationConfig names the hub shared by cells and project controllers. type FederationConfig struct { // KubeconfigPath is the path to a kubeconfig file pointing at the hub. - // Required: a cell that cannot reach the hub cannot show a consumer the - // interface behind their instance. KubeconfigPath string `json:"kubeconfigPath,omitempty"` // Client configures the Kubernetes client connection to the hub. diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go index 5a3551a8..b639bb7f 100644 --- a/internal/config/zz_generated.deepcopy.go +++ b/internal/config/zz_generated.deepcopy.go @@ -737,6 +737,7 @@ func (in *NetworkServicesOperator) DeepCopyInto(out *NetworkServicesOperator) { out.Connector = in.Connector out.Discovery = in.Discovery out.DownstreamResourceManagement = in.DownstreamResourceManagement + out.Federation = in.Federation in.Redis.DeepCopyInto(&out.Redis) out.LeaderElection = in.LeaderElection in.DomainVerification.DeepCopyInto(&out.DomainVerification) diff --git a/internal/config/zz_generated.defaults.go b/internal/config/zz_generated.defaults.go index 81fb0661..b6340abd 100644 --- a/internal/config/zz_generated.defaults.go +++ b/internal/config/zz_generated.defaults.go @@ -318,6 +318,13 @@ func SetObjectDefaults_NetworkServicesOperator(in *NetworkServicesOperator) { in.Connector.Iroh.TTLSeconds = 5 } SetDefaults_DiscoveryConfig(&in.Discovery) + SetDefaults_ClientConnectionConfig(&in.Federation.Client) + if in.Federation.Client.QPS == 0 { + in.Federation.Client.QPS = 50 + } + if in.Federation.Client.Burst == 0 { + in.Federation.Client.Burst = 100 + } if in.Redis.DialTimeout == nil { if err := json.Unmarshal([]byte(`"5s"`), &in.Redis.DialTimeout); err != nil { panic(err) diff --git a/internal/networkagent/catalog_test.go b/internal/networkagent/catalog_test.go index 17d93be9..c756fb1b 100644 --- a/internal/networkagent/catalog_test.go +++ b/internal/networkagent/catalog_test.go @@ -34,6 +34,7 @@ var outOfScopeAPIFiles = map[string]string{ "edgereachability_types.go": "hub-side record with no customer-facing status", "groupversion_info.go": "scheme registration, no reasons", "httpproxy_types.go": "load balancers; the ALB agent's", + "internaldns_types.go": "internal DNS service configuration, no status reasons", "locationbinding_types.go": "which locations a project is offered; surfaces here only as LocationNotAvailable", "networkpolicy_types.go": "declares no status reasons", "trafficprotectionpolicy_types.go": "load balancer protection; the ALB agent's", From 617582cf59be1dc2118a7498d73c2b2fc7ef79fb Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 17:57:24 -0500 Subject: [PATCH 19/25] Propagate network addressing to project network contexts --- .../networkbinding_addressing_test.go | 64 +++++++++++++++++ .../controller/networkbinding_controller.go | 69 ++++++++++++++----- 2 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 internal/controller/networkbinding_addressing_test.go diff --git a/internal/controller/networkbinding_addressing_test.go b/internal/controller/networkbinding_addressing_test.go new file mode 100644 index 00000000..f09e78cf --- /dev/null +++ b/internal/controller/networkbinding_addressing_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func TestProjectBindingCarriesNetworkAddressing(t *testing.T) { + for _, existing := range []bool{false, true} { + name := "create" + if existing { + name = "repair" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, networkingv1alpha.AddToScheme(scheme)) + network := &networkingv1alpha.Network{ObjectMeta: metav1.ObjectMeta{Name: "producer", Namespace: "default", UID: "network-id", Generation: 2}, Spec: networkingv1alpha.NetworkSpec{IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, MTU: 1440}} + binding := networkBindingForTest("default", "presence", "producer", "us-central-1") + objects := []client.Object{&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, network, binding} + if existing { + objects = append(objects, &networkingv1alpha.NetworkContext{ObjectMeta: metav1.ObjectMeta{Name: networkContextNameForBinding(binding), Namespace: "default"}}) + } + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&networkingv1alpha.NetworkBinding{}, &networkingv1alpha.NetworkContext{}).WithObjects(objects...).Build() + r := &NetworkBindingReconciler{mgr: &replicatorFakeManager{clusters: map[multicluster.ClusterName]cluster.Cluster{"project": &replicatorFakeCluster{c: c, scheme: scheme}}}} + req := mcreconcile.Request{ClusterName: "project", Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(binding)}} + _, err := r.Reconcile(ctx, req) + require.NoError(t, err) + var result networkingv1alpha.NetworkContext + require.NoError(t, c.Get(ctx, client.ObjectKey{Namespace: "default", Name: networkContextNameForBinding(binding)}, &result)) + require.Equal(t, network.Spec.IPFamilies, result.Spec.IPFamilies) + require.Equal(t, network.Spec.MTU, result.Spec.MTU) + require.Equal(t, network.Generation, result.Spec.NetworkGeneration) + require.Equal(t, string(network.UID), result.Labels[networkingv1alpha.NetworkUIDLabel]) + require.True(t, metav1.IsControlledBy(&result, network)) + network.Spec.MTU = 1400 + network.Generation++ + require.NoError(t, c.Update(ctx, network)) + requests := networkBindingRequestsForNetwork(ctx, c, "project", network) + require.Len(t, requests, 1) + _, err = r.Reconcile(ctx, requests[0]) + require.NoError(t, err) + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(&result), &result)) + require.EqualValues(t, 1400, result.Spec.MTU) + require.Equal(t, network.Generation, result.Spec.NetworkGeneration) + }) + } +} diff --git a/internal/controller/networkbinding_controller.go b/internal/controller/networkbinding_controller.go index bf700228..44a6a74d 100644 --- a/internal/controller/networkbinding_controller.go +++ b/internal/controller/networkbinding_controller.go @@ -115,27 +115,26 @@ func (r *NetworkBindingReconciler) Reconcile(ctx context.Context, req mcreconcil return ctrl.Result{}, fmt.Errorf("failed fetching network context: %w", err) } - if networkContext.CreationTimestamp.IsZero() { - networkContext = networkingv1alpha.NetworkContext{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: networkContextObjectKey.Namespace, - Name: networkContextObjectKey.Name, - }, - Spec: networkingv1alpha.NetworkContextSpec{ - Network: networkingv1alpha.LocalNetworkRef{ - Name: binding.Spec.Network.Name, - }, - Location: binding.Spec.Location, - }, + networkContext.Namespace = networkContextObjectKey.Namespace + networkContext.Name = networkContextObjectKey.Name + if _, err := controllerutil.CreateOrUpdate(ctx, cl.GetClient(), &networkContext, func() error { + if !networkContext.DeletionTimestamp.IsZero() { + return &networkContextTerminating{name: networkContext.Name} } - - if err := controllerutil.SetControllerReference(&network, &networkContext, cl.GetScheme()); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to set controller on network context: %w", err) - } - - if err := cl.GetClient().Create(ctx, &networkContext); err != nil { - return ctrl.Result{}, fmt.Errorf("failed creating network context: %w", err) + networkContext.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: network.Name} + networkContext.Spec.Location = binding.Spec.Location + networkContext.Spec.IPFamilies = append([]networkingv1alpha.IPFamily(nil), network.Spec.IPFamilies...) + networkContext.Spec.MTU = network.Spec.MTU + networkContext.Spec.NetworkGeneration = network.Generation + if networkContext.Labels == nil { + networkContext.Labels = map[string]string{} } + networkContext.Labels[networkingv1alpha.NetworkUIDLabel] = string(network.UID) + networkContext.Labels[networkingv1alpha.NetworkLabel] = network.Name + networkContext.Labels[networkingv1alpha.LocationLabel] = binding.Spec.Location.Name + return controllerutil.SetControllerReference(&network, &networkContext, cl.GetScheme()) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("failed reconciling network context: %w", err) } if !apimeta.IsStatusConditionTrue(networkContext.Status.Conditions, networkingv1alpha.NetworkContextReady) { @@ -173,6 +172,14 @@ func (r *NetworkBindingReconciler) SetupWithManager(mgr mcmanager.Manager) error For(&networkingv1alpha.NetworkBinding{}, mcbuilder.WithEngageWithLocalCluster(false), ). + Watches( + &networkingv1alpha.Network{}, + func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request { + return networkBindingRequestsForNetwork(ctx, cl.GetClient(), clusterName, obj) + }) + }, + ). Watches( &networkingv1alpha.NetworkContext{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { @@ -228,6 +235,30 @@ func networkContextNameForBinding(binding *networkingv1alpha.NetworkBinding) str return networkContextName(binding.Spec.Network.Name, binding.Spec.Location) } +func networkBindingRequestsForNetwork(ctx context.Context, c client.Client, clusterName multicluster.ClusterName, obj client.Object) []mcreconcile.Request { + network, ok := obj.(*networkingv1alpha.Network) + if !ok { + return nil + } + var bindings networkingv1alpha.NetworkBindingList + if err := c.List(ctx, &bindings); err != nil { + log.FromContext(ctx).Error(err, "failed to list NetworkBindings for Network watch") + return nil + } + var requests []mcreconcile.Request + for i := range bindings.Items { + binding := &bindings.Items[i] + namespace := binding.Spec.Network.Namespace + if namespace == "" { + namespace = binding.Namespace + } + if namespace == network.Namespace && binding.Spec.Network.Name == network.Name { + requests = append(requests, mcreconcile.Request{ClusterName: clusterName, Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(binding)}}) + } + } + return requests +} + func networkContextName(network string, location locationsv1alpha1.LocationReference) string { return fmt.Sprintf("%s-%s", network, location.Name) } From 8013c822caac5b50fec010c4e123a007b4ebec75 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 18:21:50 -0500 Subject: [PATCH 20/25] Resolve private DNS regions from platform locations --- internal/cmd/manager/manager.go | 13 +++++++++--- internal/controller/internaldns_controller.go | 13 ++++++++---- .../controller/internaldns_controller_test.go | 21 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index a8e49971..68891b8a 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -419,6 +419,10 @@ func NewCommand(build BuildInfo) *cobra.Command { os.Exit(1) } + var dnsLocations client.Reader + if projectIdentities != nil { + dnsLocations = projectIdentities.Reader + } registeredControllers, err := setupControllers(mgr, serverConfig, controllerDeps{ downstreamCluster: downstreamCluster, federationManager: federationMgr, @@ -428,6 +432,7 @@ func NewCommand(build BuildInfo) *cobra.Command { certificateServiceRoots: certificateServiceRoots, wildcardEntitlements: wildcardEntitlements, projectIdentities: projectIdentities, + dnsLocations: dnsLocations, }) if err != nil { setupLog.Error(err, "unable to set up controllers") @@ -606,6 +611,7 @@ type controllerDeps struct { certificateServiceRoots *x509.CertPool wildcardEntitlements controller.WildcardEntitlementChecker projectIdentities controller.ProjectIdentityResolver + dnsLocations client.Reader } func federationRESTConfig(federation config.FederationConfig, deployment *rest.Config) (*rest.Config, error) { @@ -615,7 +621,7 @@ func federationRESTConfig(federation config.FederationConfig, deployment *rest.C return federation.RestConfig() } -func newProjectIdentityResolver(serverConfig config.NetworkServicesOperator) (controller.ProjectIdentityResolver, error) { +func newProjectIdentityResolver(serverConfig config.NetworkServicesOperator) (*controller.APIProjectIdentityResolver, error) { if !serverConfig.InternalDNS.Enabled { return nil, nil } @@ -731,8 +737,9 @@ func controllerRegistrations( }}, {"internaldns-project", serverConfig.InternalDNS.Enabled, func() error { return (&controller.InternalDNSProjectReconciler{ - Config: serverConfig.InternalDNS, - Projects: deps.projectIdentities, + Config: serverConfig.InternalDNS, + Projects: deps.projectIdentities, + Locations: deps.dnsLocations, }).SetupWithManager(mgr) }}, {"internaldns-readiness", serverConfig.InternalDNS.Enabled, func() error { diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index c79d1a27..570b53f5 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -82,9 +82,10 @@ func (r *APIProjectIdentityResolver) ProjectUID(ctx context.Context, name string } type InternalDNSProjectReconciler struct { - Config config.InternalDNSConfig - Projects ProjectIdentityResolver - Now func() time.Time + Config config.InternalDNSConfig + Projects ProjectIdentityResolver + Locations client.Reader + Now func() time.Time mgr mcmanager.Manager } @@ -216,7 +217,11 @@ func (r *InternalDNSProjectReconciler) region( networkContext *networkingv1alpha.NetworkContext, ) (string, error) { location := &locationsv1alpha1.Location{} - if err := cl.Get(ctx, client.ObjectKey{Name: networkContext.Spec.Location.Name}, location); err != nil { + reader := r.Locations + if reader == nil { + reader = cl + } + if err := reader.Get(ctx, client.ObjectKey{Name: networkContext.Spec.Location.Name}, location); err != nil { return "", client.IgnoreNotFound(err) } return location.Spec.Topology[internalDNSRegionTopologyKey], nil diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index c66d3df3..45460899 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -384,3 +384,24 @@ func assertUnstructuredCount( t.Fatalf("%s count = %d, want %d", gvk.Kind, len(list.Items), want) } } + +func TestInternalDNSUsesPlatformLocation(t *testing.T) { + scheme := runtime.NewScheme() + if err := locationsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + location := &locationsv1alpha1.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "ord-1"}, + Spec: locationsv1alpha1.LocationSpec{Topology: map[string]string{internalDNSRegionTopologyKey: "us-central"}}, + } + platform := fake.NewClientBuilder().WithScheme(scheme).WithObjects(location).Build() + tenant := fake.NewClientBuilder().WithScheme(scheme).Build() + r := &InternalDNSProjectReconciler{Locations: platform} + region, err := r.region(context.Background(), tenant, testNetworkContext("test", "context")) + if err != nil { + t.Fatal(err) + } + if region != "us-central" { + t.Fatalf("region = %q, want us-central", region) + } +} From 943f17537c21fb3b7c740cd3399a97426dd60ab3 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 19:16:08 -0500 Subject: [PATCH 21/25] test: allocate network context subnets in readiness scenarios --- .../chainsaw-test.yaml | 31 +++------ .../ready-when-context-is-ready/README.md | 66 +------------------ .../chainsaw-test.yaml | 16 ++--- 3 files changed, 16 insertions(+), 97 deletions(-) diff --git a/test/e2e/network-context-lifecycle/chainsaw-test.yaml b/test/e2e/network-context-lifecycle/chainsaw-test.yaml index 1ccaaa98..e16661ed 100644 --- a/test/e2e/network-context-lifecycle/chainsaw-test.yaml +++ b/test/e2e/network-context-lifecycle/chainsaw-test.yaml @@ -14,7 +14,7 @@ spec: kind: Network metadata: name: test-cascade-network - namespace: default + namespace: ipam-e2e-alpha spec: ipam: mode: Auto @@ -31,7 +31,7 @@ spec: kind: NetworkBinding metadata: name: test-cascade-binding - namespace: default + namespace: ipam-e2e-alpha spec: network: name: test-cascade-network @@ -43,27 +43,12 @@ spec: kind: NetworkContext metadata: name: test-cascade-network-test-location - namespace: default + namespace: ipam-e2e-alpha (length(ownerReferences[?kind == 'Network' && controller == `true`]) == `1`): true - - name: Mark NetworkContext as Programmed - description: | - A plugin is expected to program the context at its backend and set the - Programmed condition. No such plugin runs in the local harness, so the - condition is set directly. A kubectl command is used because Chainsaw - does not support updating subresources. - See https://github.com/kyverno/chainsaw/issues/300. - try: - - script: - content: | - kubectl -n default patch networkcontext test-cascade-network-test-location \ - --subresource=status --type=merge \ - -p '{"status":{"conditions":[{"type": "Programmed", "status": "True", "reason": "Test", "message": "test", "lastTransitionTime": "2025-02-24T23:59:09Z"}]}}' - - name: Assert NetworkContext becomes Ready description: | - The networkcontext controller derives Ready from Programmed. This asserts - that transition, which no other e2e exercises. + The context becomes ready after its subnet is allocated from the project's address pool. try: - assert: timeout: 1m @@ -72,7 +57,7 @@ spec: kind: NetworkContext metadata: name: test-cascade-network-test-location - namespace: default + namespace: ipam-e2e-alpha status: (length(conditions[?type == 'Ready' && status == 'True']) == `1`): true @@ -82,7 +67,7 @@ spec: apiVersion: networking.datumapis.com/v1alpha kind: NetworkBinding name: test-cascade-binding - namespace: default + namespace: ipam-e2e-alpha timeout: 1m for: condition: @@ -102,11 +87,11 @@ spec: apiVersion: networking.datumapis.com/v1alpha kind: Network name: test-cascade-network - namespace: default + namespace: ipam-e2e-alpha - script: content: | set -e - if kubectl -n default get networkcontext test-cascade-network-test-location 2>/dev/null; then + if kubectl -n ipam-e2e-alpha get networkcontext test-cascade-network-test-location 2>/dev/null; then echo "NetworkContext still exists after Network deletion" >&2 exit 1 fi diff --git a/test/e2e/networkbinding/ready-when-context-is-ready/README.md b/test/e2e/networkbinding/ready-when-context-is-ready/README.md index d2dbde88..6789cf53 100644 --- a/test/e2e/networkbinding/ready-when-context-is-ready/README.md +++ b/test/e2e/networkbinding/ready-when-context-is-ready/README.md @@ -1,65 +1,3 @@ -# Test: `ready-when-context-is-ready` - -*No description* - -## Steps - -| # | Name | Bindings | Try | Catch | Finally | Cleanup | -|:-:|---|:-:|:-:|:-:|:-:|:-:| -| 1 | [Create Network](#step-Create Network) | 0 | 1 | 0 | 0 | 0 | -| 2 | [Create NetworkBinding](#step-Create NetworkBinding) | 0 | 3 | 0 | 0 | 0 | -| 3 | [Set NetworkContext Ready Condition to True](#step-Set NetworkContext Ready Condition to True) | 0 | 1 | 0 | 0 | 0 | -| 4 | [Assert NetworkBinding is Ready](#step-Assert NetworkBinding is Ready) | 0 | 1 | 0 | 0 | 0 | - -### Step: `Create Network` - -*No description* - -#### Try - -| # | Operation | Bindings | Outputs | Description | -|:-:|---|:-:|:-:|---| -| 1 | `create` | 0 | 0 | *No description* | - -### Step: `Create NetworkBinding` - -*No description* - -#### Try - -| # | Operation | Bindings | Outputs | Description | -|:-:|---|:-:|:-:|---| -| 1 | `create` | 0 | 0 | *No description* | -| 2 | `assert` | 0 | 0 | *No description* | -| 3 | `wait` | 0 | 0 | *No description* | - -### Step: `Set NetworkContext Ready Condition to True` - -Under normal operation, a plugin is expected to move the network context -to be ready once it has been programmed at the plugin's backend. We may -introduce a plugin that can be used during tests so that this won't be -necessary. - -A direct kubectl command is used as Chainsaw purposefully does not -support updating subresources. See https://github.com/kyverno/chainsaw/issues/300 -for more details. - - -#### Try - -| # | Operation | Bindings | Outputs | Description | -|:-:|---|:-:|:-:|---| -| 1 | `script` | 0 | 0 | *No description* | - -### Step: `Assert NetworkBinding is Ready` - -*No description* - -#### Try - -| # | Operation | Bindings | Outputs | Description | -|:-:|---|:-:|:-:|---| -| 1 | `wait` | 0 | 0 | *No description* | - ---- +# Network binding readiness +The test creates a network and binding in a project namespace with an address pool. It verifies that the binding becomes ready after its network context receives a subnet. diff --git a/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml b/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml index 60e62fb0..0cfb87ee 100644 --- a/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml +++ b/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml @@ -14,7 +14,7 @@ spec: kind: Network metadata: name: test-binding-network - namespace: default + namespace: ipam-e2e-alpha spec: ipam: mode: Auto @@ -27,7 +27,7 @@ spec: kind: NetworkBinding metadata: name: test-binding - namespace: default + namespace: ipam-e2e-alpha spec: network: name: test-binding-network @@ -39,16 +39,12 @@ spec: kind: NetworkContext metadata: name: test-binding-network-some-location - namespace: default + namespace: ipam-e2e-alpha (creationTimestamp != nil): true - name: Assert NetworkBinding is Ready description: | - This network needs no address space of its own, so its context is ready - as soon as it exists and the binding never passes through - NetworkContextNotReady. Covering that intermediate state needs a context - that waits on an address allocation, which needs an IPAM pool for this - project. + The binding becomes ready after its context receives a subnet from the project's address pool. try: - assert: timeout: 1m @@ -57,9 +53,9 @@ spec: kind: NetworkBinding metadata: name: test-binding - namespace: default + namespace: ipam-e2e-alpha status: networkContextRef: - namespace: default + namespace: ipam-e2e-alpha name: test-binding-network-some-location (length(conditions[?type == 'Ready' && status == 'True' && reason == 'NetworkContextReady']) == `1`): true From 670aa45708e8f4db7304b1a6444edb9a59d52c64 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 19:28:10 -0500 Subject: [PATCH 22/25] fix: preserve network identity for DNS publishing --- .../gateway_resource_replicator_controller.go | 1 + ...teway_resource_replicator_controller_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/controller/gateway_resource_replicator_controller.go b/internal/controller/gateway_resource_replicator_controller.go index 1ae3cafb..8baa2dc0 100644 --- a/internal/controller/gateway_resource_replicator_controller.go +++ b/internal/controller/gateway_resource_replicator_controller.go @@ -214,6 +214,7 @@ func initReplicationResourceConfigs() map[string]replicationResourceConfig { } } contextConfig := configs[gvkKey(networkContextGVK)] + contextConfig.propagatedLabels = append(contextConfig.propagatedLabels, networkingv1alpha.NetworkUIDLabel) contextConfig.propagatedAnnotations = internalDNSIntentAnnotations() configs[gvkKey(networkContextGVK)] = contextConfig diff --git a/internal/controller/gateway_resource_replicator_controller_test.go b/internal/controller/gateway_resource_replicator_controller_test.go index a8d534eb..a2e49b9d 100644 --- a/internal/controller/gateway_resource_replicator_controller_test.go +++ b/internal/controller/gateway_resource_replicator_controller_test.go @@ -40,6 +40,7 @@ import ( "sigs.k8s.io/multicluster-runtime/pkg/multicluster" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1" "go.datum.net/network-services-operator/internal/config" downstreamclient "go.datum.net/network-services-operator/internal/downstreamclient" @@ -1133,6 +1134,22 @@ func TestNetworkIsReplicatedToTheHubWithoutBeingPlaced(t *testing.T) { "nothing downstream reads a Network's status") } +func TestNetworkContextReplicationPreservesNetworkIdentity(t *testing.T) { + gvk := schema.GroupVersionKind{Group: groupNetworkingDatumAPIs, Version: versionV1Alpha, Kind: KindNetworkContext} + cfg := initReplicationResourceConfigs()[gvkKey(gvk)] + upstream := &unstructured.Unstructured{} + upstream.SetLabels(map[string]string{ + networkingv1alpha.NetworkUIDLabel: "network-a", + networkingv1alpha.NetworkLabel: "private", + networkingv1alpha.LocationLabel: "us-central-1", + "example.test/untrusted": "discard", + }) + downstream := &unstructured.Unstructured{} + propagateLabels(upstream, downstream, cfg.propagatedLabels) + assert.Equal(t, "network-a", downstream.GetLabels()[networkingv1alpha.NetworkUIDLabel]) + assert.NotContains(t, downstream.GetLabels(), "example.test/untrusted") +} + func TestNetworkContextReplicationCarriesOnlyDNSIntentAnnotations(t *testing.T) { configs := initReplicationResourceConfigs() gvk := schema.GroupVersionKind{ From 7e8bc970bdde9f9438fbcd59b908f2dbc4a1e4e6 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 19:49:44 -0500 Subject: [PATCH 23/25] fix: publish interfaces with long claim names --- internal/controller/networkinterface_projection.go | 3 ++- .../controller/networkinterface_projection_test.go | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/controller/networkinterface_projection.go b/internal/controller/networkinterface_projection.go index c7cf85f9..1cc761c2 100644 --- a/internal/controller/networkinterface_projection.go +++ b/internal/controller/networkinterface_projection.go @@ -11,6 +11,7 @@ import ( apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" @@ -64,7 +65,7 @@ func projectionLabels(source *networkingv1alpha.NetworkInterface, location strin if location != "" { labels[networkingv1alpha.NetworkInterfaceLocationLabel] = location } - if ref := source.Spec.ClaimRef; ref != nil { + if ref := source.Spec.ClaimRef; ref != nil && len(validation.IsValidLabelValue(ref.Name)) == 0 { labels[networkingv1alpha.NetworkInterfaceHolderLabel] = ref.Name } diff --git a/internal/controller/networkinterface_projection_test.go b/internal/controller/networkinterface_projection_test.go index da94dac7..a62f6be2 100644 --- a/internal/controller/networkinterface_projection_test.go +++ b/internal/controller/networkinterface_projection_test.go @@ -667,8 +667,15 @@ func TestConsumerLabelsReachTheCopy(t *testing.T) { "only the allow-listed prefixes travel to a copy") } -// A label whose source has dropped it must leave the copy. A copy is selected -// by its labels, and a stale one keeps retired capacity a member of a service. +func TestProjectionOmitsClaimNameThatExceedsLabelLimit(t *testing.T) { + source := &networkingv1alpha.NetworkInterface{Spec: networkingv1alpha.NetworkInterfaceSpec{ + ClaimRef: &networkingv1alpha.NetworkInterfaceClaimRef{Name: strings.Repeat("a", 64)}, + }} + labels := projectionLabels(source, testLocationName) + require.NotContains(t, labels, networkingv1alpha.NetworkInterfaceHolderLabel) + require.Equal(t, testLocationName, labels[networkingv1alpha.NetworkInterfaceLocationLabel]) +} + func TestACopyLosesALabelItsSourceDropped(t *testing.T) { v := newVisibility(t) iface := v.interfaceOnCell() From c84c034e52d7896f01982d309e1051c989ccb73a Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 20:23:25 -0500 Subject: [PATCH 24/25] test: exercise project-local network binding readiness --- test/e2e/network-context-lifecycle/chainsaw-test.yaml | 5 +++-- .../ready-when-context-is-ready/chainsaw-test.yaml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/e2e/network-context-lifecycle/chainsaw-test.yaml b/test/e2e/network-context-lifecycle/chainsaw-test.yaml index e16661ed..68b27101 100644 --- a/test/e2e/network-context-lifecycle/chainsaw-test.yaml +++ b/test/e2e/network-context-lifecycle/chainsaw-test.yaml @@ -31,10 +31,11 @@ spec: kind: NetworkBinding metadata: name: test-cascade-binding - namespace: ipam-e2e-alpha + namespace: default spec: network: name: test-cascade-network + namespace: ipam-e2e-alpha location: name: test-location - assert: @@ -67,7 +68,7 @@ spec: apiVersion: networking.datumapis.com/v1alpha kind: NetworkBinding name: test-cascade-binding - namespace: ipam-e2e-alpha + namespace: default timeout: 1m for: condition: diff --git a/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml b/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml index 0cfb87ee..761f5add 100644 --- a/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml +++ b/test/e2e/networkbinding/ready-when-context-is-ready/chainsaw-test.yaml @@ -27,10 +27,11 @@ spec: kind: NetworkBinding metadata: name: test-binding - namespace: ipam-e2e-alpha + namespace: default spec: network: name: test-binding-network + namespace: ipam-e2e-alpha location: name: some-location - assert: @@ -53,7 +54,7 @@ spec: kind: NetworkBinding metadata: name: test-binding - namespace: ipam-e2e-alpha + namespace: default status: networkContextRef: namespace: ipam-e2e-alpha From 2b967eac22f7fa1bbe27d6117a57e3d97352cde3 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 21 Oct 2026 21:01:16 -0500 Subject: [PATCH 25/25] fix: isolate internal DNS rollout behavior --- docs/runbooks/internal-dns.md | 34 +++++++ internal/cmd/cell/cell.go | 4 +- internal/cmd/cell/cell_test.go | 14 +++ internal/cmd/manager/manager.go | 93 ++++++++++++------- internal/cmd/manager/manager_test.go | 2 + .../controller/internaldns_cell_controller.go | 15 ++- internal/controller/internaldns_controller.go | 15 ++- .../controller/internaldns_controller_test.go | 26 ++++++ .../networkbinding_addressing_test.go | 21 ++++- .../networkbinding_context_watch_test.go | 15 +++ .../controller/networkbinding_controller.go | 93 +++++++++++++------ 11 files changed, 265 insertions(+), 67 deletions(-) create mode 100644 docs/runbooks/internal-dns.md diff --git a/docs/runbooks/internal-dns.md b/docs/runbooks/internal-dns.md new file mode 100644 index 00000000..973e696f --- /dev/null +++ b/docs/runbooks/internal-dns.md @@ -0,0 +1,34 @@ +# Internal DNS configuration + +`internalDNS.enabled` is a global rollout switch. It defaults to `false`. +When enabled, every Network with an empty DNS mode uses managed DNS under +`datum.internal`. Set a Network's `spec.dns.mode` to `Disabled` to exclude it. +Disabling the process flag stops DNS reconciliation; it does not withdraw applied +DNS records or access. Use the Network setting for an orderly withdrawal. + +Before enabling the feature: + +- Install the DNS resolver context and access binding CRDs in project control + planes, and the Galactic service endpoint and route policy CRDs in cells. +- Grant the project integration identity permission to manage resolver contexts + and access bindings. Grant the cell identity permission to manage service + endpoints and route policies. These permissions do not belong to consumers or + product record publishers. +- Allow only trusted network controllers to write NetworkContext specs in + federation and cell APIs. Their resolver settings configure guest interfaces. + Consumer roles should manage Networks in the project API instead. +- Configure federation access, DNS service destinations, and the Galactic cell + controllers before enabling the project and cell DNS integrations. + +The generated manager role includes integration permissions. A runtime feature +flag does not revoke RBAC grants. Deployments that require separate identities +must bind the project and cell permissions at their respective API boundaries. + +With DNS disabled, no internal DNS controller or additional federation manager +starts. Existing project NetworkContexts remain unchanged by NetworkBinding +reconciliation. Certificate challenge and solver controllers use the deployment +API independently of the DNS setting. + +Monitor resolver access readiness, route programming conditions, and DNS queries +from a newly created instance. Existing answers alone do not verify that new +consumer attachments can reach the resolver. diff --git a/internal/cmd/cell/cell.go b/internal/cmd/cell/cell.go index 22c55d2a..c862fd21 100644 --- a/internal/cmd/cell/cell.go +++ b/internal/cmd/cell/cell.go @@ -51,7 +51,6 @@ func init() { utilruntime.Must(networkingv1alpha1.AddToScheme(scheme)) utilruntime.Must(ipamv1alpha1.AddToScheme(scheme)) utilruntime.Must(cloudv1alpha1.AddToScheme(scheme)) - utilruntime.Must(networkv1alpha1.AddToScheme(scheme)) utilruntime.Must(nadv1.AddToScheme(scheme)) } @@ -90,6 +89,9 @@ func NewCommand() *cobra.Command { os.Exit(1) } + if serverConfig.InternalDNS.Enabled { + utilruntime.Must(networkv1alpha1.AddToScheme(scheme)) + } setupLog.Info("server config", "config", serverConfig) setupLog.Info("configured location fallback, used only until a "+ "ServingLocation is delivered to this cell", diff --git a/internal/cmd/cell/cell_test.go b/internal/cmd/cell/cell_test.go index 5128657c..2dc5bee6 100644 --- a/internal/cmd/cell/cell_test.go +++ b/internal/cmd/cell/cell_test.go @@ -36,3 +36,17 @@ func TestGalacticControllersRequireEnablement(t *testing.T) { }) } } + +func TestInternalDNSDisabledNeedsNoHubOrDNSAPIs(t *testing.T) { + for _, registration := range controllerRegistrations(nil, config.CellControllerManager{}, nil, nil) { + if registration.name != "internaldns-cell" { + continue + } + enabled, err := registration.setup() + if err != nil || enabled { + t.Fatalf("disabled DNS setup = %t, %v", enabled, err) + } + return + } + t.Fatal("DNS registration not found") +} diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index 68891b8a..954b6935 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -151,13 +151,13 @@ func NewCommand(build BuildInfo) *cobra.Command { &singletonControllersLeaderElection, "singleton-controllers-leader-elect", true, - "Enable leader election for controllers that watch the federation hub.", + "Enable leader election for singleton downstream controllers (Challenge and GatewayDownstreamCertificateSolver).", ) fs.StringVar( &singletonControllersLeaderElectionID, "singleton-controllers-leader-election-id", "", - "Leader election ID for controllers that watch the federation hub. When empty, it is derived from the enabled controller sets.", + "Leader election ID for singleton downstream controllers. When empty, it is derived from the enabled controller sets.", ) opts := zap.Options{} @@ -350,29 +350,54 @@ func NewCommand(build BuildInfo) *cobra.Command { os.Exit(1) } - // Hub controllers reconcile NetworkBindings and resources published by - // cells. Keep their cache and leader election on the federation API, - // alongside the authoritative copies they watch. - federationConfig, err := federationRESTConfig(serverConfig.Federation, cfg) - if err != nil { - setupLog.Error(err, "unable to load federation kubeconfig") - os.Exit(1) + var singletonMgr manager.Manager + singletonControllerMgr := mgr.GetLocalManager() + if enableClusterSharding { + singletonMgr, err = manager.New(cfg, manager.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), + HealthProbeBindAddress: "0", + LeaderElection: singletonControllersLeaderElection, + LeaderElectionID: singletonControllersLeaderElectionID, + LeaderElectionNamespace: leaderElectionNamespace, + LeaseDuration: &leaseDuration, + RenewDeadline: &renewDeadline, + RetryPeriod: &retryPeriod, + }) + if err != nil { + setupLog.Error(err, "unable to create singleton controller manager") + os.Exit(1) + } + singletonControllerMgr = singletonMgr } - federationMgr, err := manager.New(federationConfig, manager.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{BindAddress: "0"}, - WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), - HealthProbeBindAddress: "0", - LeaderElection: singletonControllersLeaderElection, - LeaderElectionID: singletonControllersLeaderElectionID, - LeaderElectionNamespace: leaderElectionNamespace, - LeaseDuration: &leaseDuration, - RenewDeadline: &renewDeadline, - RetryPeriod: &retryPeriod, - }) - if err != nil { - setupLog.Error(err, "unable to create federation controller manager") - os.Exit(1) + + var federationMgr manager.Manager + federationControllerMgr := singletonControllerMgr + if serverConfig.InternalDNS.Enabled { + federationConfig, err := federationRESTConfig(serverConfig.Federation, cfg) + if err != nil { + setupLog.Error(err, "unable to load federation kubeconfig") + os.Exit(1) + } + federationMgr, err = manager.New(federationConfig, manager.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + WebhookServer: webhook.NewServer(webhook.Options{Port: 0}), + HealthProbeBindAddress: "0", + LeaderElection: singletonControllersLeaderElection, + LeaderElectionID: singletonControllersLeaderElectionID + "-dns-federation", + LeaderElectionNamespace: leaderElectionNamespace, + LeaseDuration: &leaseDuration, + RenewDeadline: &renewDeadline, + RetryPeriod: &retryPeriod, + }) + if err != nil { + setupLog.Error(err, "unable to create federation controller manager") + os.Exit(1) + } + + federationControllerMgr = federationMgr } var irohDownstream cluster.Cluster @@ -425,7 +450,8 @@ func NewCommand(build BuildInfo) *cobra.Command { } registeredControllers, err := setupControllers(mgr, serverConfig, controllerDeps{ downstreamCluster: downstreamCluster, - federationManager: federationMgr, + federationManager: federationControllerMgr, + singletonManager: singletonControllerMgr, irohDownstream: irohDownstream, ipamClients: ipamClients, certificateServiceReader: certificateServiceReader, @@ -505,10 +531,12 @@ func NewCommand(build BuildInfo) *cobra.Command { return ignoreCanceled(mgr.Start(ctx)) }) - setupLog.Info("starting federation controller manager") - g.Go(func() error { - return ignoreCanceled(federationMgr.Start(ctx)) - }) + if singletonMgr != nil { + g.Go(func() error { return ignoreCanceled(singletonMgr.Start(ctx)) }) + } + if federationMgr != nil { + g.Go(func() error { return ignoreCanceled(federationMgr.Start(ctx)) }) + } if err := g.Wait(); err != nil { setupLog.Error(err, "unable to start") @@ -605,6 +633,7 @@ func setupWebhooks(mgr mcmanager.Manager, serverConfig config.NetworkServicesOpe type controllerDeps struct { downstreamCluster cluster.Cluster federationManager manager.Manager + singletonManager manager.Manager irohDownstream cluster.Cluster ipamClients controller.IPAMClientFactory certificateServiceReader client.Reader @@ -709,7 +738,7 @@ func controllerRegistrations( }).SetupWithManager(mgr) }}, {"networkbinding", true, func() error { - return (&controller.NetworkBindingReconciler{}).SetupWithManager(mgr) + return (&controller.NetworkBindingReconciler{InternalDNSEnabled: serverConfig.InternalDNS.Enabled}).SetupWithManager(mgr) }}, {"networkcontext", true, func() error { return (&controller.NetworkContextReconciler{ @@ -825,7 +854,7 @@ func controllerRegistrations( return (&controller.GatewayDownstreamCertificateSolverReconciler{ Config: serverConfig, DownstreamCluster: deps.downstreamCluster, - }).SetupWithManager(deps.federationManager) + }).SetupWithManager(deps.singletonManager) }}, {"domain", true, func() error { return (&controller.DomainReconciler{ @@ -850,7 +879,7 @@ func controllerRegistrations( return (&controller.ChallengeReconciler{ Config: serverConfig, DownstreamCluster: deps.downstreamCluster, - }).SetupWithManager(deps.federationManager) + }).SetupWithManager(deps.singletonManager) }}, } } diff --git a/internal/cmd/manager/manager_test.go b/internal/cmd/manager/manager_test.go index eb0eef7b..2c539391 100644 --- a/internal/cmd/manager/manager_test.go +++ b/internal/cmd/manager/manager_test.go @@ -99,6 +99,8 @@ func TestControllerRegistrations_CapabilityGates(t *testing.T) { "trafficprotectionpolicy", "challenge", "iroh-dns", + "internaldns-project", + "internaldns-readiness", } { if slices.Contains(got, name) { t.Errorf("expected %q to stay unregistered, got %v", name, got) diff --git a/internal/controller/internaldns_cell_controller.go b/internal/controller/internaldns_cell_controller.go index 1650fdc3..14632968 100644 --- a/internal/controller/internaldns_cell_controller.go +++ b/internal/controller/internaldns_cell_controller.go @@ -343,19 +343,30 @@ func (r *InternalDNSCellReconciler) cleanup( policy := &networkv1alpha1.ServiceRoutePolicy{ObjectMeta: metav1.ObjectMeta{ Namespace: networkContext.Namespace, Name: name, }} - if err := cl.Delete(ctx, policy); err != nil && !apierrors.IsNotFound(err) { + if err := deleteOwnedDNSRoute(ctx, cl, policy, networkContext); err != nil { errs = append(errs, err) } endpoint := &networkv1alpha1.ServiceEndpoint{ObjectMeta: metav1.ObjectMeta{ Namespace: networkContext.Namespace, Name: name, }} - if err := cl.Delete(ctx, endpoint); err != nil && !apierrors.IsNotFound(err) { + if err := deleteOwnedDNSRoute(ctx, cl, endpoint, networkContext); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } +func deleteOwnedDNSRoute(ctx context.Context, cl client.Client, obj client.Object, owner *networkingv1alpha.NetworkContext) error { + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return client.IgnoreNotFound(err) + } + if !metav1.IsControlledBy(obj, owner) { + return fmt.Errorf("DNS route %s/%s belongs to another context lifetime", obj.GetNamespace(), obj.GetName()) + } + uid, version := obj.GetUID(), obj.GetResourceVersion() + return client.IgnoreNotFound(cl.Delete(ctx, obj, client.Preconditions{UID: &uid, ResourceVersion: &version})) +} + func (r *InternalDNSCellReconciler) SetupWithManager(mgr mcmanager.Manager) error { if r.HubCluster == nil { return errors.New("a federation hub cluster is required") diff --git a/internal/controller/internaldns_controller.go b/internal/controller/internaldns_controller.go index 570b53f5..d428163d 100644 --- a/internal/controller/internaldns_controller.go +++ b/internal/controller/internaldns_controller.go @@ -354,7 +354,7 @@ func (r *InternalDNSProjectReconciler) disable( errs = append(errs, err) } contextObj := newInternalDNSObject(internalDNSContextGVK, network.Namespace, internalDNSResourceName(network.Name, network.UID)) - if err := cl.Delete(ctx, contextObj); err != nil && !apierrors.IsNotFound(err) { + if err := deleteOwnedDNSObject(ctx, cl, contextObj, string(network.UID)); err != nil { errs = append(errs, err) } if err := r.clearIntent(ctx, cl, networkContext); err != nil { @@ -368,6 +368,17 @@ func (r *InternalDNSProjectReconciler) disable( return errors.Join(errs...) } +func deleteOwnedDNSObject(ctx context.Context, cl client.Client, obj *unstructured.Unstructured, networkUID string) error { + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return client.IgnoreNotFound(err) + } + if networkUID == "" || obj.GetAnnotations()[internalDNSOwnerNetworkUIDAnnotation] != networkUID { + return fmt.Errorf("%s %s/%s belongs to another network lifetime", obj.GetKind(), obj.GetNamespace(), obj.GetName()) + } + uid, version := obj.GetUID(), obj.GetResourceVersion() + return client.IgnoreNotFound(cl.Delete(ctx, obj, client.Preconditions{UID: &uid, ResourceVersion: &version})) +} + func (r *InternalDNSProjectReconciler) cleanupRegion( ctx context.Context, cl client.Client, @@ -402,7 +413,7 @@ func (r *InternalDNSProjectReconciler) cleanupRegion( } } binding := newInternalDNSObject(internalDNSAccessGVK, networkContext.Namespace, internalDNSRegionalName(contextName, region)) - if err := cl.Delete(ctx, binding); err != nil && !apierrors.IsNotFound(err) { + if err := deleteOwnedDNSObject(ctx, cl, binding, networkContext.Labels[networkingv1alpha.NetworkUIDLabel]); err != nil { return err } return nil diff --git a/internal/controller/internaldns_controller_test.go b/internal/controller/internaldns_controller_test.go index 45460899..a88bf260 100644 --- a/internal/controller/internaldns_controller_test.go +++ b/internal/controller/internaldns_controller_test.go @@ -405,3 +405,29 @@ func TestInternalDNSUsesPlatformLocation(t *testing.T) { t.Fatalf("region = %q, want us-central", region) } } + +func TestInternalDNSCleanupPreservesForeignObjects(t *testing.T) { + for _, gvk := range []schema.GroupVersionKind{internalDNSContextGVK, internalDNSAccessGVK} { + t.Run(gvk.Kind, func(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + obj := newInternalDNSObject(gvk, "tenant", "same-name") + obj.SetUID("foreign-object") + obj.SetAnnotations(map[string]string{internalDNSOwnerNetworkUIDAnnotation: "other-network"}) + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(obj).Build() + if err := deleteOwnedDNSObject(ctx, cl, obj.DeepCopy(), "network-uid"); err == nil { + t.Fatal("foreign object deletion accepted") + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + t.Fatal(err) + } + if err := deleteOwnedDNSObject(ctx, cl, obj.DeepCopy(), "other-network"); err != nil { + t.Fatal(err) + } + if err := cl.Get(ctx, client.ObjectKeyFromObject(obj), obj); !apierrors.IsNotFound(err) { + t.Fatalf("owned object retained: %v", err) + } + }) + } +} diff --git a/internal/controller/networkbinding_addressing_test.go b/internal/controller/networkbinding_addressing_test.go index f09e78cf..268cd449 100644 --- a/internal/controller/networkbinding_addressing_test.go +++ b/internal/controller/networkbinding_addressing_test.go @@ -38,7 +38,7 @@ func TestProjectBindingCarriesNetworkAddressing(t *testing.T) { objects = append(objects, &networkingv1alpha.NetworkContext{ObjectMeta: metav1.ObjectMeta{Name: networkContextNameForBinding(binding), Namespace: "default"}}) } c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&networkingv1alpha.NetworkBinding{}, &networkingv1alpha.NetworkContext{}).WithObjects(objects...).Build() - r := &NetworkBindingReconciler{mgr: &replicatorFakeManager{clusters: map[multicluster.ClusterName]cluster.Cluster{"project": &replicatorFakeCluster{c: c, scheme: scheme}}}} + r := &NetworkBindingReconciler{InternalDNSEnabled: true, mgr: &replicatorFakeManager{clusters: map[multicluster.ClusterName]cluster.Cluster{"project": &replicatorFakeCluster{c: c, scheme: scheme}}}} req := mcreconcile.Request{ClusterName: "project", Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(binding)}} _, err := r.Reconcile(ctx, req) require.NoError(t, err) @@ -62,3 +62,22 @@ func TestProjectBindingCarriesNetworkAddressing(t *testing.T) { }) } } + +func TestProjectBindingDisabledPreservesContext(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, networkingv1alpha.AddToScheme(scheme)) + network := &networkingv1alpha.Network{ObjectMeta: metav1.ObjectMeta{Name: "producer", Namespace: "default", UID: "network-id", Generation: 3}, Spec: networkingv1alpha.NetworkSpec{IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, MTU: 1440}} + binding := networkBindingForTest("default", "presence", "producer", "us-central-1") + existing := &networkingv1alpha.NetworkContext{ObjectMeta: metav1.ObjectMeta{Name: networkContextNameForBinding(binding), Namespace: "default", CreationTimestamp: metav1.Now(), Labels: map[string]string{"custom": "retained"}}, Spec: networkingv1alpha.NetworkContextSpec{Network: networkingv1alpha.LocalNetworkRef{Name: network.Name}, Location: binding.Spec.Location, MTU: 1280}} + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&networkingv1alpha.NetworkBinding{}, &networkingv1alpha.NetworkContext{}).WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, network, binding, existing).Build() + var before networkingv1alpha.NetworkContext + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(existing), &before)) + r := &NetworkBindingReconciler{mgr: &replicatorFakeManager{clusters: map[multicluster.ClusterName]cluster.Cluster{"project": &replicatorFakeCluster{c: c, scheme: scheme}}}} + _, err := r.Reconcile(ctx, mcreconcile.Request{ClusterName: "project", Request: ctrl.Request{NamespacedName: client.ObjectKeyFromObject(binding)}}) + require.NoError(t, err) + var after networkingv1alpha.NetworkContext + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(existing), &after)) + require.Equal(t, before, after) +} diff --git a/internal/controller/networkbinding_context_watch_test.go b/internal/controller/networkbinding_context_watch_test.go index ca3a550a..dc313376 100644 --- a/internal/controller/networkbinding_context_watch_test.go +++ b/internal/controller/networkbinding_context_watch_test.go @@ -71,3 +71,18 @@ func TestNetworkBindingRequestsForContextIgnoresOtherTypes(t *testing.T) { require.Nil(t, requests) } + +func TestNetworkBindingRequestsForContextAcrossNamespaces(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, networkingv1alpha.AddToScheme(scheme)) + binding := networkBindingForTest("default", "binding", "network", "location") + binding.Spec.Network.Namespace = "project-network" + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(binding).Build() + networkContext := &networkingv1alpha.NetworkContext{ObjectMeta: metav1.ObjectMeta{Namespace: "project-network", Name: "network-location"}} + requests := networkBindingRequestsForContext(context.Background(), cl, "project", networkContext) + require.Len(t, requests, 1) + require.Equal(t, "default", requests[0].Namespace) + require.Equal(t, binding.Name, requests[0].Name) + networkContext.Namespace = "unrelated" + require.Empty(t, networkBindingRequestsForContext(context.Background(), cl, "project", networkContext)) +} diff --git a/internal/controller/networkbinding_controller.go b/internal/controller/networkbinding_controller.go index 44a6a74d..09f18b11 100644 --- a/internal/controller/networkbinding_controller.go +++ b/internal/controller/networkbinding_controller.go @@ -28,7 +28,8 @@ import ( // NetworkBindingReconciler reconciles a NetworkBinding object type NetworkBindingReconciler struct { - mgr mcmanager.Manager + mgr mcmanager.Manager + InternalDNSEnabled bool } // +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkbindings,verbs=get;list;watch;create;update;patch;delete @@ -115,26 +116,51 @@ func (r *NetworkBindingReconciler) Reconcile(ctx context.Context, req mcreconcil return ctrl.Result{}, fmt.Errorf("failed fetching network context: %w", err) } - networkContext.Namespace = networkContextObjectKey.Namespace - networkContext.Name = networkContextObjectKey.Name - if _, err := controllerutil.CreateOrUpdate(ctx, cl.GetClient(), &networkContext, func() error { - if !networkContext.DeletionTimestamp.IsZero() { - return &networkContextTerminating{name: networkContext.Name} + if !r.InternalDNSEnabled { + if networkContext.CreationTimestamp.IsZero() { + networkContext = networkingv1alpha.NetworkContext{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: networkContextObjectKey.Namespace, + Name: networkContextObjectKey.Name, + }, + Spec: networkingv1alpha.NetworkContextSpec{ + Network: networkingv1alpha.LocalNetworkRef{ + Name: binding.Spec.Network.Name, + }, + Location: binding.Spec.Location, + }, + } + + if err := controllerutil.SetControllerReference(&network, &networkContext, cl.GetScheme()); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to set controller on network context: %w", err) + } + + if err := cl.GetClient().Create(ctx, &networkContext); err != nil { + return ctrl.Result{}, fmt.Errorf("failed creating network context: %w", err) + } } - networkContext.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: network.Name} - networkContext.Spec.Location = binding.Spec.Location - networkContext.Spec.IPFamilies = append([]networkingv1alpha.IPFamily(nil), network.Spec.IPFamilies...) - networkContext.Spec.MTU = network.Spec.MTU - networkContext.Spec.NetworkGeneration = network.Generation - if networkContext.Labels == nil { - networkContext.Labels = map[string]string{} + } else { + networkContext.Namespace = networkContextObjectKey.Namespace + networkContext.Name = networkContextObjectKey.Name + if _, err := controllerutil.CreateOrUpdate(ctx, cl.GetClient(), &networkContext, func() error { + if !networkContext.DeletionTimestamp.IsZero() { + return &networkContextTerminating{name: networkContext.Name} + } + networkContext.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: network.Name} + networkContext.Spec.Location = binding.Spec.Location + networkContext.Spec.IPFamilies = append([]networkingv1alpha.IPFamily(nil), network.Spec.IPFamilies...) + networkContext.Spec.MTU = network.Spec.MTU + networkContext.Spec.NetworkGeneration = network.Generation + if networkContext.Labels == nil { + networkContext.Labels = map[string]string{} + } + networkContext.Labels[networkingv1alpha.NetworkUIDLabel] = string(network.UID) + networkContext.Labels[networkingv1alpha.NetworkLabel] = network.Name + networkContext.Labels[networkingv1alpha.LocationLabel] = binding.Spec.Location.Name + return controllerutil.SetControllerReference(&network, &networkContext, cl.GetScheme()) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("failed reconciling network context: %w", err) } - networkContext.Labels[networkingv1alpha.NetworkUIDLabel] = string(network.UID) - networkContext.Labels[networkingv1alpha.NetworkLabel] = network.Name - networkContext.Labels[networkingv1alpha.LocationLabel] = binding.Spec.Location.Name - return controllerutil.SetControllerReference(&network, &networkContext, cl.GetScheme()) - }); err != nil { - return ctrl.Result{}, fmt.Errorf("failed reconciling network context: %w", err) } if !apimeta.IsStatusConditionTrue(networkContext.Status.Conditions, networkingv1alpha.NetworkContextReady) { @@ -168,28 +194,30 @@ func (r *NetworkBindingReconciler) Reconcile(ctx context.Context, req mcreconcil // never notice the context going away, and nothing else recreates it. func (r *NetworkBindingReconciler) SetupWithManager(mgr mcmanager.Manager) error { r.mgr = mgr - return mcbuilder.ControllerManagedBy(mgr). + builder := mcbuilder.ControllerManagedBy(mgr). For(&networkingv1alpha.NetworkBinding{}, mcbuilder.WithEngageWithLocalCluster(false), ). Watches( - &networkingv1alpha.Network{}, + &networkingv1alpha.NetworkContext{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request { - return networkBindingRequestsForNetwork(ctx, cl.GetClient(), clusterName, obj) + return networkBindingRequestsForContext(ctx, cl.GetClient(), clusterName, obj) }) }, ). - Watches( - &networkingv1alpha.NetworkContext{}, + Named("networkbinding") + if r.InternalDNSEnabled { + builder = builder.Watches( + &networkingv1alpha.Network{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request { - return networkBindingRequestsForContext(ctx, cl.GetClient(), clusterName, obj) + return networkBindingRequestsForNetwork(ctx, cl.GetClient(), clusterName, obj) }) }, - ). - Named("networkbinding"). - Complete(r) + ) + } + return builder.Complete(r) } // networkBindingRequestsForContext maps a NetworkContext back to the bindings @@ -209,7 +237,7 @@ func networkBindingRequestsForContext( } var bindings networkingv1alpha.NetworkBindingList - if err := c.List(ctx, &bindings, client.InNamespace(networkContext.Namespace)); err != nil { + if err := c.List(ctx, &bindings); err != nil { logger.Error(err, "failed to list NetworkBindings for NetworkContext watch", "networkContext", networkContext.Name) return nil } @@ -217,6 +245,13 @@ func networkBindingRequestsForContext( var requests []mcreconcile.Request for i := range bindings.Items { binding := &bindings.Items[i] + networkNamespace := binding.Spec.Network.Namespace + if networkNamespace == "" { + networkNamespace = binding.Namespace + } + if networkNamespace != networkContext.Namespace { + continue + } if networkContextNameForBinding(binding) != networkContext.Name { continue }