diff --git a/api/v1alpha/groupversion_info.go b/api/v1alpha/groupversion_info.go
index 65cd78fc..1041c5bd 100644
--- a/api/v1alpha/groupversion_info.go
+++ b/api/v1alpha/groupversion_info.go
@@ -4,8 +4,9 @@
package v1alpha
import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
- "sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
@@ -13,8 +14,27 @@ var (
GroupVersion = schema.GroupVersion{Group: "compute.datumapis.com", Version: "v1alpha"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
- SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+ SchemeBuilder = &objectSchemeBuilder{}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
+
+// objectSchemeBuilder registers API objects against GroupVersion. API packages
+// must stay cheap to import, so this mirrors what the deprecated
+// controller-runtime scheme.Builder did without depending on controller-runtime.
+//
+// +kubebuilder:object:generate=false
+type objectSchemeBuilder struct {
+ runtime.SchemeBuilder
+}
+
+// Register adds one or more objects to the builder so they can be added to a scheme.
+func (b *objectSchemeBuilder) Register(objects ...runtime.Object) *objectSchemeBuilder {
+ b.SchemeBuilder.Register(func(s *runtime.Scheme) error {
+ s.AddKnownTypes(GroupVersion, objects...)
+ metav1.AddToGroupVersion(s, GroupVersion)
+ return nil
+ })
+ return b
+}
diff --git a/api/v1alpha/instance_types.go b/api/v1alpha/instance_types.go
index b280cb66..ffcf582f 100644
--- a/api/v1alpha/instance_types.go
+++ b/api/v1alpha/instance_types.go
@@ -16,8 +16,17 @@ type InstanceSpec struct {
// Network interface configuration.
//
+ // Keyed by interface name so an interface keeps its identity, and therefore
+ // its addresses, across updates to the rest of the list.
+ //
+ // Limited to a single interface until the data plane can attach more than
+ // one to an instance.
+ //
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=1
+ // +listType=map
+ // +listMapKey=name
NetworkInterfaces []InstanceNetworkInterface `json:"networkInterfaces,omitempty"`
// Volumes that must be available to attach to an instance's containers or
@@ -295,12 +304,84 @@ type InstanceRuntimeResources struct {
Requests corev1.ResourceList `json:"requests,omitempty"`
}
+// InstanceNetworkInterface describes one interface an instance needs. The
+// fields beyond `network` and `networkPolicy` are copied verbatim onto the
+// NetworkInterfaceClaim created for each instance slot, so they carry the same
+// meaning, defaults, and immutability the claim API defines.
+//
+// The location an interface is claimed in is implicit: the claim is created in
+// the control plane serving the instance, which is already location scoped.
+//
+// +kubebuilder:validation:XValidation:message="addresses is immutable and cannot be set, changed, or cleared after creation",rule="has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses) || self.addresses == oldSelf.addresses)"
type InstanceNetworkInterface struct {
// The network to attach the network interface to.
//
// +kubebuilder:validation:Required
Network networkingv1alpha.NetworkRef `json:"network"`
+ // The name of the interface, such as eth0 or eth1. It is both the device
+ // name the guest operating system sees and the suffix of the interface
+ // claim's name, which is what keeps an interface's addresses with the
+ // instance slot across replacement.
+ //
+ // Immutable, because the guest is configured against it and the claim is
+ // named after it.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=15
+ // +kubebuilder:default="eth0"
+ // +kubebuilder:validation:XValidation:message="name is immutable and cannot be changed after creation",rule="self == oldSelf"
+ Name string `json:"name,omitempty"`
+
+ // The address families the interface must carry, in priority order. List
+ // [IPv6, IPv4] for a dual-stack interface. The first family listed holds the
+ // interface's primary address, which is the one reported as the instance's
+ // network IP.
+ //
+ // Every family listed must be satisfiable or the interface is never
+ // published, so asking for a family the network does not carry fails rather
+ // than yielding a partially addressed interface.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=2
+ // +kubebuilder:default={IPv6}
+ // +kubebuilder:validation:XValidation:message="Each address family may be requested at most once",rule="self.all(f, self.exists_one(g, g == f))"
+ // +kubebuilder:validation:XValidation:message="ipFamilies is immutable and cannot be changed after creation",rule="self == oldSelf"
+ IPFamilies []networkingv1alpha.IPFamily `json:"ipFamilies,omitempty"`
+
+ // Requests for addresses beyond the ones the interface holds inside its
+ // network, such as a public IPv4 address in front of a private one. Each is
+ // reported in the interface's `externalAddresses` status.
+ //
+ // Omit this field for ordinary private addressing, which is the common case.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=4
+ // +kubebuilder:validation:XValidation:message="Each address class may be requested at most once",rule="self.all(a, self.exists_one(b, b.class == a.class))"
+ Addresses []InstanceNetworkInterfaceAddressRequest `json:"addresses,omitempty"`
+
+ // What becomes of the interface, and its addresses, when the instance slot
+ // it serves goes away.
+ //
+ // Delete returns the addresses to IPAM, so an instance recreated later comes
+ // back on different addresses. Retain keeps them reserved, and billable, so a
+ // later instance filling the same slot returns to the same addresses. Choose
+ // Retain when an address is published in DNS, allowed through a firewall, or
+ // otherwise depended on from outside.
+ //
+ // Both policies keep the addresses for as long as the slot exists, including
+ // across instance replacement. They differ only on scale-down and deletion.
+ //
+ // Immutable. An address keeps the policy it was allocated under.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:default="Delete"
+ // +kubebuilder:validation:XValidation:message="reclaimPolicy is immutable and cannot be changed after creation",rule="self == oldSelf"
+ ReclaimPolicy networkingv1alpha.NetworkInterfaceReclaimPolicy `json:"reclaimPolicy,omitempty"`
+
// Interface specific network policy.
//
// If provided, this will result in a platform managed network policy being
@@ -312,16 +393,136 @@ type InstanceNetworkInterface struct {
NetworkPolicy *InstanceNetworkInterfaceNetworkPolicy `json:"networkPolicy,omitempty"`
}
+// InstanceNetworkInterfaceAddressRequest asks for one address beyond the ones
+// the interface holds inside its network.
+type InstanceNetworkInterfaceAddressRequest struct {
+ // The IPAM class to allocate from, such as public-ipv4.
+ //
+ // A class names a kind of address, and the platform decides which pool and
+ // prefix length serve it. A class never names a pool, a prefix length, or a
+ // CIDR, so a class cannot be used to ask for a particular address.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=63
+ Class string `json:"class"`
+}
+
type InstanceNetworkInterfaceStatus struct {
+ // The name of the interface this entry reports on, matching the name in the
+ // instance's spec.
+ //
+ // +kubebuilder:validation:Optional
+ Name string `json:"name,omitempty"`
+
+ // The addresses the interface holds inside its network, each with its prefix
+ // length and, once the location has a subnet, its gateway.
+ //
+ // +kubebuilder:validation:Optional
+ Addresses []InstanceNetworkInterfaceAddress `json:"addresses,omitempty"`
+
+ // The addresses the interface is reachable at from outside its network, one
+ // per class requested in the spec. Each is a bare address with no prefix
+ // length.
+ //
+ // +kubebuilder:validation:Optional
+ ExternalAddresses []InstanceNetworkInterfaceExternalAddress `json:"externalAddresses,omitempty"`
+
+ // The observations of this interface's current state. Known condition types
+ // are "Allocated" and "Programmed".
+ //
+ // +kubebuilder:validation:Optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+ // Single address projections of the fields above, kept for clients that read
+ // one address per interface.
+ //
+ // +kubebuilder:validation:Optional
Assignments InstanceNetworkInterfaceAssignmentsStatus `json:"assignments,omitempty"`
}
+// InstanceNetworkInterfaceAddress is an address the interface holds inside its
+// network. These are configured on the NIC itself, and always carry a prefix
+// length.
+type InstanceNetworkInterfaceAddress struct {
+ // The address family of this entry.
+ //
+ // +kubebuilder:validation:Required
+ Family networkingv1alpha.IPFamily `json:"family"`
+
+ // The address the interface holds, in CIDR notation, such as 10.128.0.2/32
+ // or 2001:db8:a001::1/128.
+ //
+ // For IPv6 this may be a block delegated to the interface rather than a
+ // single address, such as 2001:db8:a001::/96. The interface owns the whole
+ // block and assigns within it.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=45
+ Address string `json:"address"`
+
+ // The next hop the interface routes through for this family, such as
+ // 10.128.0.1. It is empty until the subnet backing the network in this
+ // location exists.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MaxLength=45
+ Gateway string `json:"gateway,omitempty"`
+
+ // Marks the address projected into `assignments.networkIP`.
+ //
+ // Exactly one address is primary for the interface as a whole, not one per
+ // family. It is the address of the first family listed in `ipFamilies`.
+ //
+ // +kubebuilder:validation:Optional
+ Primary bool `json:"primary,omitempty"`
+
+ // The IPAM class this address was allocated from, such as private-ipv6. It
+ // is empty for addresses requested by family rather than by class.
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MaxLength=63
+ Class string `json:"class,omitempty"`
+}
+
+// InstanceNetworkInterfaceExternalAddress is an address reachable from outside
+// the network, mapped onto an address the interface holds inside it. A public
+// IPv4 address in front of a private address is the usual case.
+//
+// Unlike an interface address, it is a bare address with no prefix length,
+// because nothing configures it on the NIC.
+type InstanceNetworkInterfaceExternalAddress struct {
+ // The address family of this entry.
+ //
+ // +kubebuilder:validation:Required
+ Family networkingv1alpha.IPFamily `json:"family"`
+
+ // The externally reachable address, such as 203.0.113.10. It carries no
+ // prefix length.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=45
+ Address string `json:"address"`
+
+ // The IPAM class this address was allocated from, such as public-ipv4. It
+ // matches a class requested in the interface's `addresses`.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=63
+ Class string `json:"class"`
+}
+
type InstanceNetworkInterfaceAssignmentsStatus struct {
- // The IP address assigned as the primary IP from the attached network.
+ // The IP address assigned as the primary IP from the attached network. It is
+ // a projection of the primary entry in the interface's `addresses`.
NetworkIP *string `json:"networkIP,omitempty"`
// The external IP address used for the interface. A one to one NAT will be
- // performed for this address with the interface's network IP.
+ // performed for this address with the interface's network IP. It is a
+ // projection of the first entry in the interface's `externalAddresses`.
ExternalIP *string `json:"externalIP,omitempty"`
}
@@ -487,6 +688,20 @@ const (
ReferencedDataReady = "ReferencedDataReady"
)
+// Condition types reported per network interface in
+// InstanceNetworkInterfaceStatus.Conditions. They mirror the conditions the
+// networking API reports on an interface claim, so a client reads the
+// instance's interface rather than following the reference.
+const (
+ // InstanceNetworkInterfaceAllocated indicates that every requested address
+ // family, and every requested class, holds an address.
+ InstanceNetworkInterfaceAllocated = "Allocated"
+
+ // InstanceNetworkInterfaceProgrammed indicates that the data plane carries
+ // the interface's addresses. Traffic flows only once this is true.
+ InstanceNetworkInterfaceProgrammed = "Programmed"
+)
+
const (
// ReferencedDataReasonResolving indicates the resolver is in the process of
// reading source ConfigMaps/Secrets from the project control plane.
diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go
index 65d1e1e9..c67e77ea 100644
--- a/api/v1alpha/zz_generated.deepcopy.go
+++ b/api/v1alpha/zz_generated.deepcopy.go
@@ -10,7 +10,7 @@ import (
apiv1alpha "go.datum.net/network-services-operator/api/v1alpha"
"k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- runtime "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/gateway-api/apis/v1alpha2"
)
@@ -357,6 +357,16 @@ func (in *InstanceList) DeepCopyObject() runtime.Object {
func (in *InstanceNetworkInterface) DeepCopyInto(out *InstanceNetworkInterface) {
*out = *in
out.Network = in.Network
+ if in.IPFamilies != nil {
+ in, out := &in.IPFamilies, &out.IPFamilies
+ *out = make([]apiv1alpha.IPFamily, len(*in))
+ copy(*out, *in)
+ }
+ if in.Addresses != nil {
+ in, out := &in.Addresses, &out.Addresses
+ *out = make([]InstanceNetworkInterfaceAddressRequest, len(*in))
+ copy(*out, *in)
+ }
if in.NetworkPolicy != nil {
in, out := &in.NetworkPolicy, &out.NetworkPolicy
*out = new(InstanceNetworkInterfaceNetworkPolicy)
@@ -374,6 +384,36 @@ func (in *InstanceNetworkInterface) DeepCopy() *InstanceNetworkInterface {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceNetworkInterfaceAddress) DeepCopyInto(out *InstanceNetworkInterfaceAddress) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceNetworkInterfaceAddress.
+func (in *InstanceNetworkInterfaceAddress) DeepCopy() *InstanceNetworkInterfaceAddress {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceNetworkInterfaceAddress)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceNetworkInterfaceAddressRequest) DeepCopyInto(out *InstanceNetworkInterfaceAddressRequest) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceNetworkInterfaceAddressRequest.
+func (in *InstanceNetworkInterfaceAddressRequest) DeepCopy() *InstanceNetworkInterfaceAddressRequest {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceNetworkInterfaceAddressRequest)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InstanceNetworkInterfaceAssignmentsStatus) DeepCopyInto(out *InstanceNetworkInterfaceAssignmentsStatus) {
*out = *in
@@ -399,6 +439,21 @@ func (in *InstanceNetworkInterfaceAssignmentsStatus) DeepCopy() *InstanceNetwork
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceNetworkInterfaceExternalAddress) DeepCopyInto(out *InstanceNetworkInterfaceExternalAddress) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceNetworkInterfaceExternalAddress.
+func (in *InstanceNetworkInterfaceExternalAddress) DeepCopy() *InstanceNetworkInterfaceExternalAddress {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceNetworkInterfaceExternalAddress)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InstanceNetworkInterfaceNetworkPolicy) DeepCopyInto(out *InstanceNetworkInterfaceNetworkPolicy) {
*out = *in
@@ -424,6 +479,23 @@ func (in *InstanceNetworkInterfaceNetworkPolicy) DeepCopy() *InstanceNetworkInte
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InstanceNetworkInterfaceStatus) DeepCopyInto(out *InstanceNetworkInterfaceStatus) {
*out = *in
+ if in.Addresses != nil {
+ in, out := &in.Addresses, &out.Addresses
+ *out = make([]InstanceNetworkInterfaceAddress, len(*in))
+ copy(*out, *in)
+ }
+ if in.ExternalAddresses != nil {
+ in, out := &in.ExternalAddresses, &out.ExternalAddresses
+ *out = make([]InstanceNetworkInterfaceExternalAddress, len(*in))
+ copy(*out, *in)
+ }
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
in.Assignments.DeepCopyInto(&out.Assignments)
}
diff --git a/cmd/main.go b/cmd/main.go
index ab19da30..cb59e59e 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -348,7 +348,9 @@ func main() {
}
if enableManagementControllers {
- if err = (&controller.WorkloadReconciler{}).SetupWithManager(mgr); err != nil {
+ if err = (&controller.WorkloadReconciler{
+ NetworkingEnabled: features.FeatureGate.Enabled(features.NetworkingIntegration),
+ }).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Workload")
os.Exit(1)
}
diff --git a/config/base/crd/bases/compute.datumapis.com_instances.yaml b/config/base/crd/bases/compute.datumapis.com_instances.yaml
index cabe9d9f..5d3a8daa 100644
--- a/config/base/crd/bases/compute.datumapis.com_instances.yaml
+++ b/config/base/crd/bases/compute.datumapis.com_instances.yaml
@@ -105,9 +105,97 @@ spec:
- namespace
type: object
networkInterfaces:
- description: Network interface configuration.
+ description: |-
+ Network interface configuration.
+
+ Keyed by interface name so an interface keeps its identity, and therefore
+ its addresses, across updates to the rest of the list.
+
+ Limited to a single interface until the data plane can attach more than
+ one to an instance.
items:
+ description: |-
+ InstanceNetworkInterface describes one interface an instance needs. The
+ fields beyond `network` and `networkPolicy` are copied verbatim onto the
+ NetworkInterfaceClaim created for each instance slot, so they carry the same
+ meaning, defaults, and immutability the claim API defines.
+
+ The location an interface is claimed in is implicit: the claim is created in
+ the control plane serving the instance, which is already location scoped.
properties:
+ addresses:
+ description: |-
+ Requests for addresses beyond the ones the interface holds inside its
+ network, such as a public IPv4 address in front of a private one. Each is
+ reported in the interface's `externalAddresses` status.
+
+ Omit this field for ordinary private addressing, which is the common case.
+ items:
+ description: |-
+ InstanceNetworkInterfaceAddressRequest asks for one address beyond the ones
+ the interface holds inside its network.
+ properties:
+ class:
+ description: |-
+ The IPAM class to allocate from, such as public-ipv4.
+
+ A class names a kind of address, and the platform decides which pool and
+ prefix length serve it. A class never names a pool, a prefix length, or a
+ CIDR, so a class cannot be used to ask for a particular address.
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - class
+ type: object
+ maxItems: 4
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address class may be requested at most once
+ rule: self.all(a, self.exists_one(b, b.class == a.class))
+ ipFamilies:
+ default:
+ - IPv6
+ description: |-
+ The address families the interface must carry, in priority order. List
+ [IPv6, IPv4] for a dual-stack interface. The first family listed holds the
+ interface's primary address, which is the one reported as the instance's
+ network IP.
+
+ Every family listed must be satisfiable or the interface is never
+ published, so asking for a family the network does not carry fails rather
+ than yielding a partially addressed interface.
+ items:
+ enum:
+ - IPv4
+ - IPv6
+ type: string
+ maxItems: 2
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address family may be requested at most once
+ rule: self.all(f, self.exists_one(g, g == f))
+ - message: ipFamilies is immutable and cannot be changed after
+ creation
+ rule: self == oldSelf
+ name:
+ default: eth0
+ description: |-
+ The name of the interface, such as eth0 or eth1. It is both the device
+ name the guest operating system sees and the suffix of the interface
+ claim's name, which is what keeps an interface's addresses with the
+ instance slot across replacement.
+
+ Immutable, because the guest is configured against it and the claim is
+ named after it.
+ maxLength: 15
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: name is immutable and cannot be changed after creation
+ rule: self == oldSelf
network:
description: The network to attach the network interface to.
properties:
@@ -214,11 +302,44 @@ spec:
type: object
type: array
type: object
+ reclaimPolicy:
+ default: Delete
+ description: |-
+ What becomes of the interface, and its addresses, when the instance slot
+ it serves goes away.
+
+ Delete returns the addresses to IPAM, so an instance recreated later comes
+ back on different addresses. Retain keeps them reserved, and billable, so a
+ later instance filling the same slot returns to the same addresses. Choose
+ Retain when an address is published in DNS, allowed through a firewall, or
+ otherwise depended on from outside.
+
+ Both policies keep the addresses for as long as the slot exists, including
+ across instance replacement. They differ only on scale-down and deletion.
+
+ Immutable. An address keeps the policy it was allocated under.
+ enum:
+ - Delete
+ - Retain
+ type: string
+ x-kubernetes-validations:
+ - message: reclaimPolicy is immutable and cannot be changed
+ after creation
+ rule: self == oldSelf
required:
- network
type: object
+ x-kubernetes-validations:
+ - message: addresses is immutable and cannot be set, changed, or
+ cleared after creation
+ rule: has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses)
+ || self.addresses == oldSelf.addresses)
+ maxItems: 1
minItems: 1
type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
runtime:
description: The runtime type of the instance, such as a container
sandbox or a VM.
@@ -1022,18 +1143,180 @@ spec:
description: Network interface information
items:
properties:
+ addresses:
+ description: |-
+ The addresses the interface holds inside its network, each with its prefix
+ length and, once the location has a subnet, its gateway.
+ items:
+ description: |-
+ InstanceNetworkInterfaceAddress is an address the interface holds inside its
+ network. These are configured on the NIC itself, and always carry a prefix
+ length.
+ properties:
+ address:
+ description: |-
+ The address the interface holds, in CIDR notation, such as 10.128.0.2/32
+ or 2001:db8:a001::1/128.
+
+ For IPv6 this may be a block delegated to the interface rather than a
+ single address, such as 2001:db8:a001::/96. The interface owns the whole
+ block and assigns within it.
+ maxLength: 45
+ minLength: 1
+ type: string
+ class:
+ description: |-
+ The IPAM class this address was allocated from, such as private-ipv6. It
+ is empty for addresses requested by family rather than by class.
+ maxLength: 63
+ type: string
+ family:
+ description: The address family of this entry.
+ enum:
+ - IPv4
+ - IPv6
+ type: string
+ gateway:
+ description: |-
+ The next hop the interface routes through for this family, such as
+ 10.128.0.1. It is empty until the subnet backing the network in this
+ location exists.
+ maxLength: 45
+ type: string
+ primary:
+ description: |-
+ Marks the address projected into `assignments.networkIP`.
+
+ Exactly one address is primary for the interface as a whole, not one per
+ family. It is the address of the first family listed in `ipFamilies`.
+ type: boolean
+ required:
+ - address
+ - family
+ type: object
+ type: array
assignments:
+ description: |-
+ Single address projections of the fields above, kept for clients that read
+ one address per interface.
properties:
externalIP:
description: |-
The external IP address used for the interface. A one to one NAT will be
- performed for this address with the interface's network IP.
+ performed for this address with the interface's network IP. It is a
+ projection of the first entry in the interface's `externalAddresses`.
type: string
networkIP:
- description: The IP address assigned as the primary IP from
- the attached network.
+ description: |-
+ The IP address assigned as the primary IP from the attached network. It is
+ a projection of the primary entry in the interface's `addresses`.
type: string
type: object
+ conditions:
+ description: |-
+ The observations of this interface's current state. Known condition types
+ are "Allocated" and "Programmed".
+ items:
+ description: Condition contains details for one aspect of
+ the current state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False,
+ Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ externalAddresses:
+ description: |-
+ The addresses the interface is reachable at from outside its network, one
+ per class requested in the spec. Each is a bare address with no prefix
+ length.
+ items:
+ description: |-
+ InstanceNetworkInterfaceExternalAddress is an address reachable from outside
+ the network, mapped onto an address the interface holds inside it. A public
+ IPv4 address in front of a private address is the usual case.
+
+ Unlike an interface address, it is a bare address with no prefix length,
+ because nothing configures it on the NIC.
+ properties:
+ address:
+ description: |-
+ The externally reachable address, such as 203.0.113.10. It carries no
+ prefix length.
+ maxLength: 45
+ minLength: 1
+ type: string
+ class:
+ description: |-
+ The IPAM class this address was allocated from, such as public-ipv4. It
+ matches a class requested in the interface's `addresses`.
+ maxLength: 63
+ minLength: 1
+ type: string
+ family:
+ description: The address family of this entry.
+ enum:
+ - IPv4
+ - IPv6
+ type: string
+ required:
+ - address
+ - class
+ - family
+ type: object
+ type: array
+ name:
+ description: |-
+ The name of the interface this entry reports on, matching the name in the
+ instance's spec.
+ type: string
type: object
type: array
suspended:
diff --git a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
index 75b77feb..391987d1 100644
--- a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
+++ b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
@@ -218,9 +218,100 @@ spec:
- namespace
type: object
networkInterfaces:
- description: Network interface configuration.
+ description: |-
+ Network interface configuration.
+
+ Keyed by interface name so an interface keeps its identity, and therefore
+ its addresses, across updates to the rest of the list.
+
+ Limited to a single interface until the data plane can attach more than
+ one to an instance.
items:
+ description: |-
+ InstanceNetworkInterface describes one interface an instance needs. The
+ fields beyond `network` and `networkPolicy` are copied verbatim onto the
+ NetworkInterfaceClaim created for each instance slot, so they carry the same
+ meaning, defaults, and immutability the claim API defines.
+
+ The location an interface is claimed in is implicit: the claim is created in
+ the control plane serving the instance, which is already location scoped.
properties:
+ addresses:
+ description: |-
+ Requests for addresses beyond the ones the interface holds inside its
+ network, such as a public IPv4 address in front of a private one. Each is
+ reported in the interface's `externalAddresses` status.
+
+ Omit this field for ordinary private addressing, which is the common case.
+ items:
+ description: |-
+ InstanceNetworkInterfaceAddressRequest asks for one address beyond the ones
+ the interface holds inside its network.
+ properties:
+ class:
+ description: |-
+ The IPAM class to allocate from, such as public-ipv4.
+
+ A class names a kind of address, and the platform decides which pool and
+ prefix length serve it. A class never names a pool, a prefix length, or a
+ CIDR, so a class cannot be used to ask for a particular address.
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - class
+ type: object
+ maxItems: 4
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address class may be requested at most
+ once
+ rule: self.all(a, self.exists_one(b, b.class == a.class))
+ ipFamilies:
+ default:
+ - IPv6
+ description: |-
+ The address families the interface must carry, in priority order. List
+ [IPv6, IPv4] for a dual-stack interface. The first family listed holds the
+ interface's primary address, which is the one reported as the instance's
+ network IP.
+
+ Every family listed must be satisfiable or the interface is never
+ published, so asking for a family the network does not carry fails rather
+ than yielding a partially addressed interface.
+ items:
+ enum:
+ - IPv4
+ - IPv6
+ type: string
+ maxItems: 2
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address family may be requested at most
+ once
+ rule: self.all(f, self.exists_one(g, g == f))
+ - message: ipFamilies is immutable and cannot be changed
+ after creation
+ rule: self == oldSelf
+ name:
+ default: eth0
+ description: |-
+ The name of the interface, such as eth0 or eth1. It is both the device
+ name the guest operating system sees and the suffix of the interface
+ claim's name, which is what keeps an interface's addresses with the
+ instance slot across replacement.
+
+ Immutable, because the guest is configured against it and the claim is
+ named after it.
+ maxLength: 15
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: name is immutable and cannot be changed after
+ creation
+ rule: self == oldSelf
network:
description: The network to attach the network interface
to.
@@ -328,11 +419,44 @@ spec:
type: object
type: array
type: object
+ reclaimPolicy:
+ default: Delete
+ description: |-
+ What becomes of the interface, and its addresses, when the instance slot
+ it serves goes away.
+
+ Delete returns the addresses to IPAM, so an instance recreated later comes
+ back on different addresses. Retain keeps them reserved, and billable, so a
+ later instance filling the same slot returns to the same addresses. Choose
+ Retain when an address is published in DNS, allowed through a firewall, or
+ otherwise depended on from outside.
+
+ Both policies keep the addresses for as long as the slot exists, including
+ across instance replacement. They differ only on scale-down and deletion.
+
+ Immutable. An address keeps the policy it was allocated under.
+ enum:
+ - Delete
+ - Retain
+ type: string
+ x-kubernetes-validations:
+ - message: reclaimPolicy is immutable and cannot be
+ changed after creation
+ rule: self == oldSelf
required:
- network
type: object
+ x-kubernetes-validations:
+ - message: addresses is immutable and cannot be set, changed,
+ or cleared after creation
+ rule: has(self.addresses) == has(oldSelf.addresses) &&
+ (!has(self.addresses) || self.addresses == oldSelf.addresses)
+ maxItems: 1
minItems: 1
type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
runtime:
description: The runtime type of the instance, such as a container
sandbox or a VM.
diff --git a/config/base/crd/bases/compute.datumapis.com_workloads.yaml b/config/base/crd/bases/compute.datumapis.com_workloads.yaml
index f2af09dc..2b8e3dfd 100644
--- a/config/base/crd/bases/compute.datumapis.com_workloads.yaml
+++ b/config/base/crd/bases/compute.datumapis.com_workloads.yaml
@@ -222,9 +222,100 @@ spec:
- namespace
type: object
networkInterfaces:
- description: Network interface configuration.
+ description: |-
+ Network interface configuration.
+
+ Keyed by interface name so an interface keeps its identity, and therefore
+ its addresses, across updates to the rest of the list.
+
+ Limited to a single interface until the data plane can attach more than
+ one to an instance.
items:
+ description: |-
+ InstanceNetworkInterface describes one interface an instance needs. The
+ fields beyond `network` and `networkPolicy` are copied verbatim onto the
+ NetworkInterfaceClaim created for each instance slot, so they carry the same
+ meaning, defaults, and immutability the claim API defines.
+
+ The location an interface is claimed in is implicit: the claim is created in
+ the control plane serving the instance, which is already location scoped.
properties:
+ addresses:
+ description: |-
+ Requests for addresses beyond the ones the interface holds inside its
+ network, such as a public IPv4 address in front of a private one. Each is
+ reported in the interface's `externalAddresses` status.
+
+ Omit this field for ordinary private addressing, which is the common case.
+ items:
+ description: |-
+ InstanceNetworkInterfaceAddressRequest asks for one address beyond the ones
+ the interface holds inside its network.
+ properties:
+ class:
+ description: |-
+ The IPAM class to allocate from, such as public-ipv4.
+
+ A class names a kind of address, and the platform decides which pool and
+ prefix length serve it. A class never names a pool, a prefix length, or a
+ CIDR, so a class cannot be used to ask for a particular address.
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - class
+ type: object
+ maxItems: 4
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address class may be requested at most
+ once
+ rule: self.all(a, self.exists_one(b, b.class == a.class))
+ ipFamilies:
+ default:
+ - IPv6
+ description: |-
+ The address families the interface must carry, in priority order. List
+ [IPv6, IPv4] for a dual-stack interface. The first family listed holds the
+ interface's primary address, which is the one reported as the instance's
+ network IP.
+
+ Every family listed must be satisfiable or the interface is never
+ published, so asking for a family the network does not carry fails rather
+ than yielding a partially addressed interface.
+ items:
+ enum:
+ - IPv4
+ - IPv6
+ type: string
+ maxItems: 2
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: Each address family may be requested at most
+ once
+ rule: self.all(f, self.exists_one(g, g == f))
+ - message: ipFamilies is immutable and cannot be changed
+ after creation
+ rule: self == oldSelf
+ name:
+ default: eth0
+ description: |-
+ The name of the interface, such as eth0 or eth1. It is both the device
+ name the guest operating system sees and the suffix of the interface
+ claim's name, which is what keeps an interface's addresses with the
+ instance slot across replacement.
+
+ Immutable, because the guest is configured against it and the claim is
+ named after it.
+ maxLength: 15
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: name is immutable and cannot be changed after
+ creation
+ rule: self == oldSelf
network:
description: The network to attach the network interface
to.
@@ -332,11 +423,44 @@ spec:
type: object
type: array
type: object
+ reclaimPolicy:
+ default: Delete
+ description: |-
+ What becomes of the interface, and its addresses, when the instance slot
+ it serves goes away.
+
+ Delete returns the addresses to IPAM, so an instance recreated later comes
+ back on different addresses. Retain keeps them reserved, and billable, so a
+ later instance filling the same slot returns to the same addresses. Choose
+ Retain when an address is published in DNS, allowed through a firewall, or
+ otherwise depended on from outside.
+
+ Both policies keep the addresses for as long as the slot exists, including
+ across instance replacement. They differ only on scale-down and deletion.
+
+ Immutable. An address keeps the policy it was allocated under.
+ enum:
+ - Delete
+ - Retain
+ type: string
+ x-kubernetes-validations:
+ - message: reclaimPolicy is immutable and cannot be
+ changed after creation
+ rule: self == oldSelf
required:
- network
type: object
+ x-kubernetes-validations:
+ - message: addresses is immutable and cannot be set, changed,
+ or cleared after creation
+ rule: has(self.addresses) == has(oldSelf.addresses) &&
+ (!has(self.addresses) || self.addresses == oldSelf.addresses)
+ maxItems: 1
minItems: 1
type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
runtime:
description: The runtime type of the instance, such as a container
sandbox or a VM.
@@ -1188,6 +1312,21 @@ spec:
true'
maxItems: 16
type: array
+ x-kubernetes-list-type: atomic
+ attachedListenerSets:
+ description: |-
+ AttachedListenerSets represents the total number of ListenerSets that have been
+ successfully attached to this Gateway.
+
+ A ListenerSet is successfully attached to a Gateway when all the following conditions are met:
+ - The ListenerSet is selected by the Gateway's AllowedListeners field
+ - The ListenerSet has a valid ParentRef selecting the Gateway
+ - The ListenerSet's status has the condition "Accepted: true"
+
+ Uses for this field include troubleshooting AttachedListenerSets attachment and
+ measuring blast radius/impact of changes to a Gateway.
+ format: int32
+ type: integer
conditions:
default:
- lastTransitionTime: "1970-01-01T00:00:00Z"
@@ -1213,6 +1352,34 @@ spec:
* "Accepted"
* "Programmed"
* "Ready"
+
+
+ Notes for implementors:
+
+ Conditions are a listType `map`, which means that they function like a
+ map with a key of the `type` field _in the k8s apiserver_.
+
+ This means that implementations must obey some rules when updating this
+ section.
+
+ * Implementations MUST perform a read-modify-write cycle on this field
+ before modifying it. That is, when modifying this field, implementations
+ must be confident they have fetched the most recent version of this field,
+ and ensure that changes they make are on that recent version.
+ * Implementations MUST NOT remove or reorder Conditions that they are not
+ directly responsible for. For example, if an implementation sees a Condition
+ with type `special.io/SomeField`, it MUST NOT remove, change or update that
+ Condition.
+ * Implementations MUST always _merge_ changes into Conditions of the same Type,
+ rather than creating more than one Condition of the same Type.
+ * Implementations MUST always update the `observedGeneration` field of the
+ Condition to the `metadata.generation` of the Gateway at the time of update creation.
+ * If the `observedGeneration` of a Condition is _greater than_ the value the
+ implementation knows about, then it MUST NOT perform the update on that Condition,
+ but must wait for a future reconciliation and status update. (The assumption is that
+ the implementation's copy of the object is stale and an update will be re-triggered
+ if relevant.)
+
items:
description: Condition contains details for one aspect of the
current state of this API Resource.
@@ -1294,16 +1461,48 @@ spec:
attachment semantics can be found in the documentation on the various
Route kinds ParentRefs fields). Listener or Route status does not impact
successful attachment, i.e. the AttachedRoutes field count MUST be set
- for Listeners with condition Accepted: false and MUST count successfully
- attached Routes that may themselves have Accepted: false conditions.
+ for Listeners, even if the Accepted condition of an individual Listener is set
+ to "False". The AttachedRoutes number represents the number of Routes with
+ the Accepted condition set to "True" that have been attached to this Listener.
+ Routes with any other value for the Accepted condition MUST NOT be included
+ in this count.
Uses for this field include troubleshooting Route attachment and
measuring blast radius/impact of changes to a Listener.
format: int32
type: integer
conditions:
- description: Conditions describe the current condition of
- this listener.
+ description: |-
+ Conditions describe the current condition of this listener.
+
+
+ Notes for implementors:
+
+ Conditions are a listType `map`, which means that they function like a
+ map with a key of the `type` field _in the k8s apiserver_.
+
+ This means that implementations must obey some rules when updating this
+ section.
+
+ * Implementations MUST perform a read-modify-write cycle on this field
+ before modifying it. That is, when modifying this field, implementations
+ must be confident they have fetched the most recent version of this field,
+ and ensure that changes they make are on that recent version.
+ * Implementations MUST NOT remove or reorder Conditions that they are not
+ directly responsible for. For example, if an implementation sees a Condition
+ with type `special.io/SomeField`, it MUST NOT remove, change or update that
+ Condition.
+ * Implementations MUST always _merge_ changes into Conditions of the same Type,
+ rather than creating more than one Condition of the same Type.
+ * Implementations MUST always update the `observedGeneration` field of the
+ Condition to the `metadata.generation` of the Gateway at the time of update creation.
+ * If the `observedGeneration` of a Condition is _greater than_ the value the
+ implementation knows about, then it MUST NOT perform the update on that Condition,
+ but must wait for a future reconciliation and status update. (The assumption is that
+ the implementation's copy of the object is stale and an update will be re-triggered
+ if relevant.)
+
+
items:
description: Condition contains details for one aspect
of the current state of this API Resource.
@@ -1375,7 +1574,7 @@ spec:
supportedKinds:
description: |-
SupportedKinds is the list indicating the Kinds supported by this
- listener. This MUST represent the kinds an implementation supports for
+ listener. This MUST represent the kinds supported by an implementation for
that Listener configuration.
If kinds are specified in Spec that are not supported, they MUST NOT
@@ -1404,11 +1603,11 @@ spec:
type: object
maxItems: 8
type: array
+ x-kubernetes-list-type: atomic
required:
- attachedRoutes
- conditions
- name
- - supportedKinds
type: object
maxItems: 64
type: array
diff --git a/config/components/controller_rbac/role.yaml b/config/components/controller_rbac/role.yaml
index 747de509..236bff0b 100644
--- a/config/components/controller_rbac/role.yaml
+++ b/config/components/controller_rbac/role.yaml
@@ -80,9 +80,8 @@ rules:
- networking.datumapis.com
resources:
- locations
- - networkcontexts
+ - networkinterfaces
- networks
- - subnets
verbs:
- get
- list
@@ -90,8 +89,7 @@ rules:
- apiGroups:
- networking.datumapis.com
resources:
- - networkbindings
- - subnetclaims
+ - networkinterfaceclaims
verbs:
- create
- delete
diff --git a/go.mod b/go.mod
index 332997ca..b9e749fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,61 +1,75 @@
module go.datum.net/compute
-go 1.26.0
+go 1.26.4
require (
github.com/KimMachineGun/automemlimit v0.7.5
github.com/google/go-cmp v0.7.0
github.com/karmada-io/api v1.15.0
- github.com/onsi/ginkgo/v2 v2.27.2
+ github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.42.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
- go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359
+ // TODO: temporary pin to the NetworkInterfaceClaim branch (datum-cloud/network-services-operator#360).
+ // Re-pin to a tagged release before merging.
+ go.datum.net/network-services-operator v0.25.6-0.20260813185515-6a019e3fd9b8
go.miloapis.com/milo v0.32.0
- golang.org/x/crypto v0.53.0
- golang.org/x/sync v0.21.0
- google.golang.org/protobuf v1.36.11
- k8s.io/api v0.35.0
- k8s.io/apimachinery v0.35.0
- k8s.io/client-go v0.35.0
- k8s.io/component-base v0.35.0
- k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
- sigs.k8s.io/controller-runtime v0.23.3
- sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c
+ golang.org/x/crypto v0.54.0
+ golang.org/x/sync v0.22.0
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
+ k8s.io/api v0.36.1
+ k8s.io/apimachinery v0.36.1
+ k8s.io/client-go v0.36.1
+ k8s.io/component-base v0.36.1
+ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5
+ sigs.k8s.io/controller-runtime v0.24.1
+ sigs.k8s.io/gateway-api v1.5.1
sigs.k8s.io/multicluster-runtime v0.23.3
)
require (
- cel.dev/expr v0.24.0 // indirect
- github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/go-openapi/swag/cmdutils v0.25.4 // 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
+ github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
+ github.com/go-openapi/swag/loading v0.26.0 // indirect
+ github.com/go-openapi/swag/mangling v0.26.0 // indirect
+ github.com/go-openapi/swag/netutils v0.25.4 // indirect
+ github.com/go-openapi/swag/stringutils v0.26.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.26.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
+ k8s.io/streaming v0.36.1 // indirect
+)
+
+require (
+ cel.dev/expr v0.25.2 // indirect
+ github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/emicklei/go-restful/v3 v3.12.2 // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
- github.com/go-openapi/jsonpointer v0.21.1 // indirect
- github.com/go-openapi/jsonreference v0.21.0 // indirect
- github.com/go-openapi/swag v0.23.1 // indirect
+ github.com/go-openapi/jsonpointer v0.23.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.6 // indirect
+ github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
- github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.0 // indirect
- github.com/google/gnostic-models v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -64,47 +78,47 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
- github.com/spf13/cobra v1.10.0 // indirect
- github.com/spf13/pflag v1.0.9 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.miloapis.com/service-catalog v0.4.0
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
- go.opentelemetry.io/otel v1.43.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
- go.opentelemetry.io/otel/metric v1.43.0 // indirect
- go.opentelemetry.io/otel/sdk v1.43.0 // indirect
- go.opentelemetry.io/otel/trace v1.43.0 // indirect
- go.opentelemetry.io/proto/otlp v1.7.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
+ 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/exp v0.0.0-20250718183923-645b1fa84792 // indirect
- golang.org/x/mod v0.36.0 // indirect
+ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
+ golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
- golang.org/x/oauth2 v0.34.0 // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/term v0.44.0 // indirect
- golang.org/x/text 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 // indirect
+ golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
- golang.org/x/tools v0.45.0 // indirect
+ golang.org/x/tools v0.47.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
- google.golang.org/grpc v1.74.2 // 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
+ google.golang.org/grpc v1.81.1 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/apiextensions-apiserver v0.35.0 // indirect
- k8s.io/apiserver v0.35.0 // indirect
- k8s.io/klog/v2 v2.130.1 // indirect
- k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
- sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
+ k8s.io/apiextensions-apiserver v0.36.1
+ k8s.io/apiserver v0.36.1 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
+ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
- sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
- sigs.k8s.io/yaml v1.6.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
+ sigs.k8s.io/yaml v1.6.0
)
diff --git a/go.sum b/go.sum
index b29f6393..380e5aaf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,17 +1,17 @@
-cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
-cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk=
github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM=
-github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
-github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -19,16 +19,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
-github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
@@ -44,55 +44,77 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
-github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
-github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
-github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
-github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
-github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
-github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
+github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
+github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
+github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
+github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
+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/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=
+github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
+github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
+github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
+github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
+github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
+github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
+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/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=
+github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
+github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
+github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
+github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
+github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
-github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
-github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
-github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
-github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/karmada-io/api v1.15.0 h1:6Dx+Q36LaoPqKM4gduUuhSBQ3eKjKusjkvmggLpt9xs=
github.com/karmada-io/api v1.15.0/go.mod h1:wNbBEmXYkrRLSC2VgmXizIG12FW+/sAUF7UIz5WlYAU=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
-github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -105,8 +127,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
-github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
+github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
@@ -125,11 +147,11 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0=
-github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE=
-github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -153,74 +175,76 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
-go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 h1:P3dePA6cCXKimZzE6d7Xxpj2rz54BxOHI8K8ic7VQ+c=
-go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359/go.mod h1:Nr0PsCodkTW31vWVxR9dhAP9w0y+WHUYeyrcRnchcIE=
+go.datum.net/network-services-operator v0.25.6-0.20260813185515-6a019e3fd9b8 h1:R82aXI8iC+6JMm5WjLxpBJRgRTPUACLlvxOW4svZ03Y=
+go.datum.net/network-services-operator v0.25.6-0.20260813185515-6a019e3fd9b8/go.mod h1:oz57/uTB5HAjLGrqs1pn1HTP/jW9vkGgMODPvCiUlvc=
go.miloapis.com/milo v0.32.0 h1:TkNIQu/37d+SEquLJ5+GmdisSl+K2RT7eEC4idg6RIs=
go.miloapis.com/milo v0.32.0/go.mod h1:GKK3afjCwshfZfvhjNe1wp/H45z4m7x5oG/8xbSgU1M=
go.miloapis.com/service-catalog v0.4.0 h1:LvO1WCHMCoFokpS5igWMP8kyqly9gUFQmQj5IGhwuKs=
go.miloapis.com/service-catalog v0.4.0/go.mod h1:1OfIYkdWH0lpbUH1d0Dc4A1yCtyNpcaKh50H6faG45A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
-go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
-go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
-go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
-go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
-go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
-go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
-go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
-go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
-go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
-go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
-go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
-go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
-golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+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=
gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
-google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -231,37 +255,39 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
-k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
-k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=
-k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=
-k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
-k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
-k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4=
-k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds=
-k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
-k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
-k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94=
-k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0=
-k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
-k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
-k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
-k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
-k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
-k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
-sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80=
-sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
-sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c h1:GS4VnGRV90GEUjrgQ2GT5ii6yzWj3KtgUg+sVMdhs5c=
-sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk=
+k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
+k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
+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/apiserver v0.36.1 h1:iMS5V+rPUertv5P9RaqJgmHHTuh4quWpoxchvMUY+JY=
+k8s.io/apiserver v0.36.1/go.mod h1:Cby1PbLWztu0GDOxoO6iFOyyqIsziHNEW+w9zVQ22Kw=
+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/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=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg=
+k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+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=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0=
+sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png=
sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs=
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-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs=
-sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+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/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/internal/config/groupversion_info.go b/internal/config/groupversion_info.go
index df87c6c6..3cf7d602 100644
--- a/internal/config/groupversion_info.go
+++ b/internal/config/groupversion_info.go
@@ -1,8 +1,9 @@
package config
import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
- "sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
@@ -10,8 +11,27 @@ var (
GroupVersion = schema.GroupVersion{Group: "apiserver.config.datumapis.com", Version: "v1alpha1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
- SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+ SchemeBuilder = &objectSchemeBuilder{}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
+
+// objectSchemeBuilder registers API objects against GroupVersion. API packages
+// must stay cheap to import, so this mirrors what the deprecated
+// controller-runtime scheme.Builder did without depending on controller-runtime.
+//
+// +kubebuilder:object:generate=false
+type objectSchemeBuilder struct {
+ runtime.SchemeBuilder
+}
+
+// Register adds one or more objects to the builder so they can be added to a scheme.
+func (b *objectSchemeBuilder) Register(objects ...runtime.Object) *objectSchemeBuilder {
+ b.SchemeBuilder.Register(func(s *runtime.Scheme) error {
+ s.AddKnownTypes(GroupVersion, objects...)
+ metav1.AddToGroupVersion(s, GroupVersion)
+ return nil
+ })
+ return b
+}
diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go
index bb0ab884..ad16b1f9 100644
--- a/internal/config/zz_generated.deepcopy.go
+++ b/internal/config/zz_generated.deepcopy.go
@@ -8,7 +8,7 @@ package config
import (
"k8s.io/api/core/v1"
- runtime "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
diff --git a/internal/controller/indexers.go b/internal/controller/indexers.go
index 6d6e4a54..65ea258c 100644
--- a/internal/controller/indexers.go
+++ b/internal/controller/indexers.go
@@ -17,8 +17,8 @@ const (
deploymentWorkloadUIDIndex = "deploymentWorkloadUIDIndex"
workloadNetworksIndex = "workloadNetworksIndex"
// deploymentCityCodeIndex indexes WorkloadDeployments by their Spec.CityCode
- // so that SubnetClaim/Subnet watches can efficiently find the deployments
- // that target the same city as a changed networking resource.
+ // so that the Location watch can efficiently find the deployments targeting
+ // the city a changed Location serves.
deploymentCityCodeIndex = "deploymentCityCodeIndex"
deploymentLocationIndex = "deploymentLocationIndex"
diff --git a/internal/controller/instance_controller.go b/internal/controller/instance_controller.go
index de2aba24..4dfb8358 100644
--- a/internal/controller/instance_controller.go
+++ b/internal/controller/instance_controller.go
@@ -234,11 +234,11 @@ type InstanceReconciler struct {
// disable federation write-back (e.g. in non-federation deployments).
FederationClient client.Client
// NetworkingEnabled mirrors the NetworkingIntegration feature gate. When false
- // the reconciler skips the NetworkBinding readiness check: cells without the
- // networking CRDs installed would otherwise fail every reconcile on a
- // "no matches for kind NetworkBinding" RESTMapper error, aborting before the
- // scheduling gates are cleared and wedging the instance in Pending. The
- // WorkloadDeployment controller honors the same gate for NetworkBinding
+ // the reconciler neither reads interface claims nor publishes their addresses:
+ // cells without the networking CRDs installed would otherwise fail every
+ // reconcile on a "no matches for kind NetworkInterfaceClaim" RESTMapper error,
+ // aborting before the scheduling gates are cleared and wedging the instance in
+ // Pending. The WorkloadDeployment controller honors the same gate for claim
// creation and the Network scheduling gate.
NetworkingEnabled bool
finalizers finalizer.Finalizers
@@ -248,6 +248,8 @@ type InstanceReconciler struct {
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=instances/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=instances/finalizers,verbs=update
// +kubebuilder:rbac:groups=quota.miloapis.com,resources=resourceclaims,verbs=get;list;watch;create;delete
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims,verbs=get;list;watch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
@@ -328,11 +330,21 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Requ
}
statusChanged = refDataResult.conditionChanged || statusChanged
+ // Publish the addresses the instance's interface claims hold. This runs
+ // before the Ready condition so a claim rejection reported there is visible
+ // alongside the per-interface conditions that explain it. Its error is held
+ // like the ones below, so whatever was learned is persisted first.
+ interfacesChanged, interfacesErr := r.reconcileNetworkInterfaceStatus(ctx, cl.GetClient(), &instance)
+ statusChanged = interfacesChanged || statusChanged
+
// Transient errors from the quota and Ready-condition reconciles are
// returned only after any condition change has been persisted, so the
// failure reason is visible on the Instance while controller-runtime
// requeues with backoff.
readyChanged, readyErr := r.reconcileInstanceReadyCondition(ctx, cl.GetClient(), &instance, r.checkForNetworkCreationFailure)
+ if readyErr == nil && interfacesErr != nil {
+ readyErr = fmt.Errorf("failed reconciling network interface status: %w", interfacesErr)
+ }
if statusChanged || readyChanged {
if err := cl.GetClient().Status().Update(ctx, &instance); err != nil {
@@ -1855,43 +1867,98 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition), nil
}
-// Rough way to propagate creation errors up to the instance as soon as possible.
-// Lots of room for improvement here.
-func (r *InstanceReconciler) checkForNetworkCreationFailure(ctx context.Context, upstreamClient client.Client, instance *computev1alpha.Instance) (failed bool, message string, err error) {
- // When the networking integration is disabled there are no NetworkBindings to
- // check. The NetworkBinding CRD is not installed on cells that don't run the
- // networking integration, so this lookup would otherwise fail with a
- // "no matches for kind NetworkBinding" RESTMapper error and wedge the reconcile
- // before the scheduling gates are cleared. Report no failure.
+// checkForNetworkCreationFailure reports whether any of the instance's interface
+// claims has been refused, so the reason reaches the user on Ready rather than
+// leaving the instance gated with no explanation.
+//
+// The claim's own reason and message are passed through verbatim. NSO refuses a
+// claim with a reason naming the cause — NetworkNotFound, AddressPoolExhausted,
+// RetainedAddressConflict and the like — and those are written to be read by
+// whoever has to act on them.
+func (r *InstanceReconciler) checkForNetworkCreationFailure(ctx context.Context, clusterClient client.Client, instance *computev1alpha.Instance) (failed bool, message string, err error) {
+ // The claim CRD is not installed on cells that don't run the networking
+ // integration, so this lookup would otherwise fail with a "no matches for
+ // kind NetworkInterfaceClaim" RESTMapper error and wedge the reconcile before
+ // the scheduling gates are cleared. Report no failure.
if !r.NetworkingEnabled {
return false, "", nil
}
- workloadDeployment, err := r.fetchOwnerWorkloadDeployment(ctx, upstreamClient, instance)
- if err != nil {
- return false, "", fmt.Errorf("failed fetching workload deployment: %w", err)
- }
+ for _, networkInterface := range instance.Spec.NetworkInterfaces {
+ interfaceName := instanceInterfaceName(networkInterface)
- for i := range instance.Spec.NetworkInterfaces {
- var networkBinding networkingv1alpha.NetworkBinding
- networkBindingObjectKey := client.ObjectKey{
- Namespace: workloadDeployment.Namespace,
- Name: fmt.Sprintf("%s-net-%d", workloadDeployment.Name, i),
+ var claim networkingv1alpha.NetworkInterfaceClaim
+ key := client.ObjectKey{
+ Namespace: instance.Namespace,
+ Name: networkInterfaceClaimName(instance.Name, interfaceName),
}
-
- if err := upstreamClient.Get(ctx, networkBindingObjectKey, &networkBinding); client.IgnoreNotFound(err) != nil {
- return false, "", fmt.Errorf("failed checking for existing network binding: %w", err)
+ if err := clusterClient.Get(ctx, key, &claim); err != nil {
+ if apierrors.IsNotFound(err) {
+ // The deployment reconciler has not created it yet; that is a wait,
+ // not a failure.
+ continue
+ }
+ return false, "", fmt.Errorf("failed fetching network interface claim: %w", err)
}
- condition := apimeta.FindStatusCondition(networkBinding.Status.Conditions, networkingv1alpha.NetworkBindingReady)
- if condition != nil && condition.Status == metav1.ConditionFalse && condition.Reason == "NetworkFailedToCreate" {
- return true, condition.Message, nil
+ if reason, claimMessage := networkInterfaceClaimRejection(&claim); reason != "" {
+ return true, fmt.Sprintf("Interface %q: %s: %s", interfaceName, reason, claimMessage), nil
}
}
return false, "", nil
}
+// reconcileNetworkInterfaceStatus publishes the addresses the instance's
+// interface claims hold onto the instance status, so a client reads one object
+// instead of following a claim per interface. The claim is the source of truth
+// for these addresses; the status is a copy of it.
+//
+// Returns whether the status changed. The caller persists it.
+func (r *InstanceReconciler) reconcileNetworkInterfaceStatus(
+ ctx context.Context,
+ clusterClient client.Client,
+ instance *computev1alpha.Instance,
+) (bool, error) {
+ if !r.NetworkingEnabled {
+ return false, nil
+ }
+
+ // Left nil rather than empty so an instance with no interfaces compares equal
+ // to its own published status instead of rewriting it on every pass.
+ var interfaces []computev1alpha.InstanceNetworkInterfaceStatus
+
+ for _, networkInterface := range instance.Spec.NetworkInterfaces {
+ interfaceName := instanceInterfaceName(networkInterface)
+
+ var claim networkingv1alpha.NetworkInterfaceClaim
+ key := client.ObjectKey{
+ Namespace: instance.Namespace,
+ Name: networkInterfaceClaimName(instance.Name, interfaceName),
+ }
+ err := clusterClient.Get(ctx, key, &claim)
+ if err != nil && !apierrors.IsNotFound(err) {
+ return false, fmt.Errorf("failed fetching network interface claim: %w", err)
+ }
+
+ if apierrors.IsNotFound(err) {
+ // Report the interface by name while its claim is still being created,
+ // so the shape of the status matches the spec from the start.
+ interfaces = append(interfaces, instanceNetworkInterfaceStatus(interfaceName, nil))
+ continue
+ }
+
+ interfaces = append(interfaces, instanceNetworkInterfaceStatus(interfaceName, &claim))
+ }
+
+ if apiequality.Semantic.DeepEqual(instance.Status.NetworkInterfaces, interfaces) {
+ return false, nil
+ }
+
+ instance.Status.NetworkInterfaces = interfaces
+ return true, nil
+}
+
// resolveProjectID delegates to projectIDForInstance; when nil it falls back
// to string(clusterName) (Milo mode).
func (r *InstanceReconciler) resolveProjectID(ctx context.Context, clusterName multicluster.ClusterName, instance *computev1alpha.Instance) (string, error) {
@@ -2006,6 +2073,13 @@ func (r *InstanceReconciler) SetupWithManager(
b := mcbuilder.ControllerManagedBy(mgr).
For(&computev1alpha.Instance{}, mcbuilder.WithEngageWithLocalCluster(false))
+ // A claim binding and allocating is what fills in the instance's addresses,
+ // and no instance event follows it. Registered only with the networking
+ // integration on, because the claim CRD is absent on cells without it.
+ if r.NetworkingEnabled {
+ b = b.Owns(&networkingv1alpha.NetworkInterfaceClaim{}, mcbuilder.WithEngageWithLocalCluster(false))
+ }
+
// The direct ResourceClaim watch resolves the quota.miloapis.com CRD only on
// the Milo project control planes, so the caller gates registration on
// watchProviderClaims (milo mode only); in single/cluster mode it is never
diff --git a/internal/controller/instance_controller_test.go b/internal/controller/instance_controller_test.go
index fc30cd5a..4b11fc12 100644
--- a/internal/controller/instance_controller_test.go
+++ b/internal/controller/instance_controller_test.go
@@ -2,6 +2,7 @@ package controller
import (
"context"
+ "errors"
"fmt"
"strings"
"testing"
@@ -28,6 +29,8 @@ import (
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+
"go.datum.net/compute/internal/controller/instancecontrol"
"go.datum.net/compute/internal/quota"
quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1"
@@ -586,15 +589,18 @@ func TestReconcileQuota(t *testing.T) {
}
}
- newReconciler := func(t *testing.T, projectObjs []client.Object, quotaObjs []client.Object) (*InstanceReconciler, client.Client, client.Client) {
+ newReconciler := func(t *testing.T, projectObjs []client.Object, quotaObjs []client.Object, projectInterceptors ...interceptor.Funcs) (*InstanceReconciler, client.Client, client.Client) {
t.Helper()
s := newTestScheme(t)
- projectClient := fake.NewClientBuilder().
+ projectBuilder := fake.NewClientBuilder().
WithScheme(s).
WithObjects(projectObjs...).
- WithStatusSubresource(&computev1alpha.Instance{}).
- Build()
+ WithStatusSubresource(&computev1alpha.Instance{})
+ for _, funcs := range projectInterceptors {
+ projectBuilder = projectBuilder.WithInterceptorFuncs(funcs)
+ }
+ projectClient := projectBuilder.Build()
quotaClient := fake.NewClientBuilder().
WithScheme(s).
@@ -670,15 +676,27 @@ func TestReconcileQuota(t *testing.T) {
t.Run("ready-condition reconcile error: quota condition persisted before the error returns", func(t *testing.T) {
s := newTestScheme(t)
// A scheduling gate keeps the Ready-condition reconcile on the network
- // failure checker path, and the missing owner reference makes that
- // checker fail.
+ // failure checker path, and an interface whose claim cannot be read makes
+ // that checker fail.
instance := makeInstance(s,
computev1alpha.SchedulingGate{Name: instancecontrol.QuotaSchedulingGate.String()},
)
- instance.OwnerReferences = nil
+ instance.Spec.NetworkInterfaces = []computev1alpha.InstanceNetworkInterface{{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ }}
claim := makeClaim(s, metav1.ConditionTrue, quotav1alpha1.ResourceClaimGrantedReason)
- r, projectClient, _ := newReconciler(t, []client.Object{instance, makeDeployment()}, []client.Object{claim})
+ failClaimReads := interceptor.Funcs{
+ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
+ if _, ok := obj.(*networkingv1alpha.NetworkInterfaceClaim); ok {
+ return errors.New("network interface claim read failed")
+ }
+ return c.Get(ctx, key, obj, opts...)
+ },
+ }
+
+ r, projectClient, _ := newReconciler(t, []client.Object{instance, makeDeployment()}, []client.Object{claim}, failClaimReads)
// The network failure checker only runs when the networking integration is
// enabled; enable it so this test exercises that path.
r.NetworkingEnabled = true
@@ -2596,14 +2614,15 @@ func TestReconcileInstanceReadyCondition_ReferencedDataEnrichment(t *testing.T)
// TestCheckForNetworkCreationFailure_NetworkingDisabled verifies that when the
// networking integration is disabled the check is a no-op and never touches the
-// client. On cells that don't run the networking integration the NetworkBinding
-// CRD is absent, so a lookup would fail with a "no matches for kind NetworkBinding"
-// RESTMapper error and wedge the reconcile before scheduling gates are cleared.
+// client. On cells that don't run the networking integration the claim CRD is
+// absent, so a lookup would fail with a "no matches for kind
+// NetworkInterfaceClaim" RESTMapper error and wedge the reconcile before
+// scheduling gates are cleared.
// A nil client here would panic if the method attempted any lookup.
func TestCheckForNetworkCreationFailure_NetworkingDisabled(t *testing.T) {
instance := &computev1alpha.Instance{
Spec: computev1alpha.InstanceSpec{
- // A network interface would normally drive a NetworkBinding lookup.
+ // A network interface would normally drive a claim lookup.
NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{{}},
},
}
diff --git a/internal/controller/instancecontrol/controller_utils.go b/internal/controller/instancecontrol/controller_utils.go
index 8dd890ee..87db3ae7 100644
--- a/internal/controller/instancecontrol/controller_utils.go
+++ b/internal/controller/instancecontrol/controller_utils.go
@@ -5,8 +5,8 @@ import (
"hash"
"hash/fnv"
- "k8s.io/apimachinery/pkg/util/dump"
"k8s.io/apimachinery/pkg/util/rand"
+ "k8s.io/utils/dump"
)
// ComputeHash returns a hash value calculated from pod template and
diff --git a/internal/controller/instancecontrol/stateful/stateful_control.go b/internal/controller/instancecontrol/stateful/stateful_control.go
index c9be87d3..352983c3 100644
--- a/internal/controller/instancecontrol/stateful/stateful_control.go
+++ b/internal/controller/instancecontrol/stateful/stateful_control.go
@@ -25,7 +25,7 @@ const (
type Options struct {
// NetworkingEnabled controls whether the Network scheduling gate is added to
// newly created Instances. Set to false when the networking integration is
- // disabled so that Instances are not blocked waiting for a NetworkBinding.
+ // disabled so that Instances are not blocked waiting for their addresses.
// Defaults to true.
NetworkingEnabled bool
diff --git a/internal/controller/networkinterfaceclaim.go b/internal/controller/networkinterfaceclaim.go
new file mode 100644
index 00000000..54f9efa3
--- /dev/null
+++ b/internal/controller/networkinterfaceclaim.go
@@ -0,0 +1,195 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/netip"
+ "strings"
+
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+const (
+ // maxObjectNameLength is the DNS subdomain limit a claim name must respect.
+ maxObjectNameLength = 253
+
+ // claimNameHashLen is the length of the digest appended when a derived name
+ // would exceed the limit, matching the fallback NSO uses for IPClaim names.
+ claimNameHashLen = 12
+
+ // defaultInterfaceName mirrors the API default for an interface that does not
+ // name itself. Claims are named after the interface, so a name is needed even
+ // for objects written before the field existed.
+ defaultInterfaceName = "eth0"
+)
+
+// networkInterfaceClaimName derives a claim name from the instance slot and the
+// interface within it. The name identifies the slot rather than the instance
+// object, so an instance replaced by another filling the same slot derives the
+// same name, binds the interface already there, and returns to the addresses it
+// was holding — which is what makes reclaimPolicy Retain observable.
+//
+// The truncate-and-hash fallback mirrors NSO's own derivation so an over-long
+// name still yields a valid, stable DNS subdomain rather than a rejected write.
+func networkInterfaceClaimName(instanceName, interfaceName string) string {
+ candidate := instanceName + "-" + interfaceName
+ if len(candidate) <= maxObjectNameLength && len(validation.IsDNS1123Subdomain(candidate)) == 0 {
+ return candidate
+ }
+
+ sum := sha256.Sum256([]byte(instanceName + "\x00" + interfaceName))
+ suffix := hex.EncodeToString(sum[:])[:claimNameHashLen]
+
+ prefix := instanceName
+ if limit := maxObjectNameLength - 1 - claimNameHashLen; len(prefix) > limit {
+ prefix = prefix[:limit]
+ }
+ return strings.TrimRight(prefix, "-.") + "-" + suffix
+}
+
+// instanceInterfaceName is the device name an interface presents to the guest,
+// falling back to the API default when the field is unset.
+func instanceInterfaceName(networkInterface computev1alpha.InstanceNetworkInterface) string {
+ if networkInterface.Name == "" {
+ return defaultInterfaceName
+ }
+ return networkInterface.Name
+}
+
+// desiredNetworkInterfaceClaimSpec copies an instance's interface request onto a
+// claim spec. Every field carries the meaning, defaults, and immutability the
+// claim API defines, so they are copied verbatim. The location is deliberately
+// absent: the claim is served by the control plane the instance runs in, which
+// is already location scoped. networkInterfaceName is left unset so the claim
+// binds the interface of its own name — the retained interface, when there is
+// one.
+func desiredNetworkInterfaceClaimSpec(networkInterface computev1alpha.InstanceNetworkInterface) networkingv1alpha.NetworkInterfaceClaimSpec {
+ spec := networkingv1alpha.NetworkInterfaceClaimSpec{
+ Network: networkingv1alpha.LocalNetworkRef{Name: networkInterface.Network.Name},
+ InterfaceName: instanceInterfaceName(networkInterface),
+ IPFamilies: append([]networkingv1alpha.IPFamily(nil), networkInterface.IPFamilies...),
+ ReclaimPolicy: networkInterface.ReclaimPolicy,
+ }
+
+ for _, address := range networkInterface.Addresses {
+ spec.Addresses = append(spec.Addresses, networkingv1alpha.NetworkInterfaceAddressRequest{
+ Class: address.Class,
+ })
+ }
+
+ return spec
+}
+
+// networkInterfaceClaimSatisfied reports whether a claim holds the addresses the
+// instance needs to boot.
+//
+// Bound and Allocated are the whole criterion. Programmed is deliberately not
+// consulted: no component sets it today, so it stays Unknown forever, and the
+// Ready condition that summarizes it stays Unknown with it. Gating on either
+// would hold every instance back indefinitely. Tighten this to Ready once a data
+// plane owns Programmed.
+func networkInterfaceClaimSatisfied(claim *networkingv1alpha.NetworkInterfaceClaim) bool {
+ return apimeta.IsStatusConditionTrue(claim.Status.Conditions, networkingv1alpha.NetworkInterfaceClaimBound) &&
+ apimeta.IsStatusConditionTrue(claim.Status.Conditions, networkingv1alpha.NetworkInterfaceClaimAllocated)
+}
+
+// networkInterfaceClaimRejection returns the reason and message of the first
+// condition reporting that a claim cannot be fulfilled, or ("", "") while it is
+// merely pending. NSO's rejection reasons (NetworkNotFound, AddressPoolExhausted,
+// RetainedAddressConflict, and so on) are designed to be read by a person, so
+// they are passed through rather than collapsed into a generic failure.
+func networkInterfaceClaimRejection(claim *networkingv1alpha.NetworkInterfaceClaim) (reason, message string) {
+ for _, conditionType := range []string{
+ networkingv1alpha.NetworkInterfaceClaimBound,
+ networkingv1alpha.NetworkInterfaceClaimAllocated,
+ } {
+ condition := apimeta.FindStatusCondition(claim.Status.Conditions, conditionType)
+ if condition != nil && condition.Status == metav1.ConditionFalse {
+ return condition.Reason, condition.Message
+ }
+ }
+ return "", ""
+}
+
+// instanceNetworkInterfaceStatus projects a claim's published addresses onto the
+// instance status entry for one interface.
+func instanceNetworkInterfaceStatus(
+ interfaceName string,
+ claim *networkingv1alpha.NetworkInterfaceClaim,
+) computev1alpha.InstanceNetworkInterfaceStatus {
+ status := computev1alpha.InstanceNetworkInterfaceStatus{Name: interfaceName}
+ if claim == nil {
+ return status
+ }
+
+ for _, address := range claim.Status.Addresses {
+ status.Addresses = append(status.Addresses, computev1alpha.InstanceNetworkInterfaceAddress{
+ Family: address.Family,
+ Address: address.Address,
+ Gateway: address.Gateway,
+ Primary: address.Primary,
+ Class: address.Class,
+ })
+ if address.Primary {
+ status.Assignments.NetworkIP = new(networkIPProjection(address.Address))
+ }
+ }
+
+ for _, address := range claim.Status.ExternalAddresses {
+ status.ExternalAddresses = append(status.ExternalAddresses, computev1alpha.InstanceNetworkInterfaceExternalAddress{
+ Family: address.Family,
+ Address: address.Address,
+ Class: address.Class,
+ })
+ }
+ if len(status.ExternalAddresses) > 0 {
+ status.Assignments.ExternalIP = new(status.ExternalAddresses[0].Address)
+ }
+
+ // Only the conditions describing the interface itself are mirrored. Bound and
+ // Ready describe the claim object, which is an implementation detail of how
+ // the interface was obtained.
+ for _, conditionType := range []string{
+ networkingv1alpha.NetworkInterfaceClaimAllocated,
+ networkingv1alpha.NetworkInterfaceClaimProgrammed,
+ } {
+ if condition := apimeta.FindStatusCondition(claim.Status.Conditions, conditionType); condition != nil {
+ mirrored := *condition
+ // The claim's generation says nothing about the instance, and carrying
+ // it would invite a reader to compare it against the wrong object.
+ mirrored.ObservedGeneration = 0
+ status.Conditions = append(status.Conditions, mirrored)
+ }
+ }
+
+ return status
+}
+
+// networkIPProjection reduces an interface address to the single value clients
+// read as "the instance's IP".
+//
+// A host address (/32 or /128) is reported bare, because that is what every
+// existing consumer of assignments.networkIP expects. Anything shorter is a
+// block delegated to the interface — an IPv6 /96, say — where no single address
+// is the instance's, so the CIDR is reported unchanged rather than silently
+// presenting a network address as a host one. This matches NSO's own bare
+// address derivation.
+func networkIPProjection(address string) string {
+ prefix, err := netip.ParsePrefix(address)
+ if err != nil {
+ // Not a CIDR: the value is already bare, or malformed and better surfaced
+ // than swallowed.
+ return address
+ }
+ if prefix.Bits() != prefix.Addr().BitLen() {
+ return address
+ }
+ return prefix.Addr().String()
+}
diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go
new file mode 100644
index 00000000..b27a0f9a
--- /dev/null
+++ b/internal/controller/networkinterfaceclaim_controller_test.go
@@ -0,0 +1,370 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+
+ "go.datum.net/compute/internal/controller/instancecontrol"
+)
+
+const (
+ claimTestNamespace = "ns-aabbccdd-0000-1111-2222-333344445555"
+ claimTestDeployment = "claim-test-wd"
+ claimTestNetwork = "default"
+
+ // claimTestClass and the addresses below mirror what NSO publishes on a
+ // bound claim: a class-allocated external address, and an interface address
+ // in CIDR notation.
+ claimTestClass = "public-ipv4"
+ claimTestAddress = "10.128.0.2"
+ claimTestAddressCIDR = claimTestAddress + "/32"
+ claimTestExternalIP = "203.0.113.10"
+)
+
+// newClaimTestScheme builds a scheme carrying compute and networking types, the
+// pair a cell serves once the networking integration is on.
+func newClaimTestScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ _ = computev1alpha.AddToScheme(s)
+ _ = networkingv1alpha.AddToScheme(s)
+ return s
+}
+
+// newClaimTestDeployment builds a deployment shaped the way the federator
+// delivers it to a cell.
+func newClaimTestDeployment() *computev1alpha.WorkloadDeployment {
+ return &computev1alpha.WorkloadDeployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: claimTestDeployment,
+ Namespace: claimTestNamespace,
+ UID: "claim-test-wd-uid",
+ },
+ Spec: computev1alpha.WorkloadDeploymentSpec{
+ CityCode: wdControllerTestCityCode,
+ WorkloadRef: computev1alpha.WorkloadReference{Name: "claim-test-workload"},
+ },
+ }
+}
+
+// newClaimTestInstance builds an instance with the given interfaces and the
+// scheduling gates the instance-control strategy stamps at creation.
+func newClaimTestInstance(name string, interfaces ...computev1alpha.InstanceNetworkInterface) *computev1alpha.Instance {
+ return &computev1alpha.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: claimTestNamespace,
+ CreationTimestamp: metav1.Now(),
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: computev1alpha.GroupVersion.String(),
+ Kind: kindWorkloadDeployment,
+ Name: claimTestDeployment,
+ UID: "claim-test-wd-uid",
+ Controller: new(true),
+ }},
+ },
+ Spec: computev1alpha.InstanceSpec{
+ NetworkInterfaces: interfaces,
+ Controller: &computev1alpha.InstanceController{
+ SchedulingGates: []computev1alpha.SchedulingGate{
+ {Name: instancecontrol.NetworkSchedulingGate.String()},
+ {Name: instancecontrol.QuotaSchedulingGate.String()},
+ },
+ },
+ },
+ }
+}
+
+// TestReconcileNetworkInterfaceClaims_CreatesClaimPerInterface verifies one
+// claim is created per instance interface, named after the slot, owned by the
+// instance, and carrying the interface request verbatim.
+func TestReconcileNetworkInterfaceClaims_CreatesClaimPerInterface(t *testing.T) {
+ t.Parallel()
+
+ deployment := newClaimTestDeployment()
+ instance := newClaimTestInstance(claimTestDeployment+"-0",
+ computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol},
+ },
+ computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: "eth1",
+ IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol},
+ ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyRetain,
+ Addresses: []computev1alpha.InstanceNetworkInterfaceAddressRequest{
+ {Class: claimTestClass},
+ },
+ },
+ )
+
+ cl := fake.NewClientBuilder().
+ WithScheme(newClaimTestScheme()).
+ WithObjects(deployment, instance).
+ Build()
+
+ r := &WorkloadDeploymentReconciler{NetworkingEnabled: true}
+ ready, err := r.reconcileNetworkInterfaceClaims(context.Background(), cl, deployment,
+ []computev1alpha.Instance{*instance})
+ require.NoError(t, err)
+ assert.False(t, ready[instance.Name],
+ "a freshly created claim holds no addresses yet")
+
+ var claims networkingv1alpha.NetworkInterfaceClaimList
+ require.NoError(t, cl.List(context.Background(), &claims, client.InNamespace(claimTestNamespace)))
+ require.Len(t, claims.Items, 2)
+
+ byName := map[string]networkingv1alpha.NetworkInterfaceClaim{}
+ for _, claim := range claims.Items {
+ byName[claim.Name] = claim
+ }
+
+ eth0, ok := byName[instance.Name+"-eth0"]
+ require.True(t, ok, "the claim is named after the instance slot and the interface")
+ assert.Equal(t, claimTestNetwork, eth0.Spec.Network.Name)
+ assert.Equal(t, defaultInterfaceName, eth0.Spec.InterfaceName)
+ assert.Equal(t, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, eth0.Spec.IPFamilies)
+ assert.Empty(t, eth0.Spec.NetworkInterfaceName)
+
+ owner := metav1.GetControllerOf(ð0)
+ require.NotNil(t, owner)
+ assert.Equal(t, "Instance", owner.Kind,
+ "the claim is owned by the instance, so ending the slot releases it")
+ assert.Equal(t, instance.Name, owner.Name)
+
+ eth1, ok := byName[instance.Name+"-eth1"]
+ require.True(t, ok)
+ assert.Equal(t, networkingv1alpha.NetworkInterfaceReclaimPolicyRetain, eth1.Spec.ReclaimPolicy)
+ require.Len(t, eth1.Spec.Addresses, 1)
+ assert.Equal(t, claimTestClass, eth1.Spec.Addresses[0].Class)
+}
+
+// TestReconcileNetworkInterfaceClaims_ReadinessPerInstance verifies readiness is
+// reported per instance: an instance whose claims are bound and allocated is
+// ready even while a sibling's claim is still pending.
+func TestReconcileNetworkInterfaceClaims_ReadinessPerInstance(t *testing.T) {
+ t.Parallel()
+
+ deployment := newClaimTestDeployment()
+ networkInterface := computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ }
+ allocated := newClaimTestInstance(claimTestDeployment+"-0", networkInterface)
+ pending := newClaimTestInstance(claimTestDeployment+"-1", networkInterface)
+
+ allocatedClaim := &networkingv1alpha.NetworkInterfaceClaim{
+ ObjectMeta: metav1.ObjectMeta{Name: allocated.Name + "-eth0", Namespace: claimTestNamespace},
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionTrue, "Allocated"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimProgrammed, metav1.ConditionUnknown, "Pending"),
+ },
+ },
+ }
+ pendingClaim := &networkingv1alpha.NetworkInterfaceClaim{
+ ObjectMeta: metav1.ObjectMeta{Name: pending.Name + "-eth0", Namespace: claimTestNamespace},
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionFalse, "AddressPoolExhausted"),
+ },
+ },
+ }
+
+ cl := fake.NewClientBuilder().
+ WithScheme(newClaimTestScheme()).
+ WithObjects(deployment, allocated, pending, allocatedClaim, pendingClaim).
+ Build()
+
+ r := &WorkloadDeploymentReconciler{NetworkingEnabled: true}
+ ready, err := r.reconcileNetworkInterfaceClaims(context.Background(), cl, deployment,
+ []computev1alpha.Instance{*allocated, *pending})
+ require.NoError(t, err)
+
+ assert.True(t, ready[allocated.Name],
+ "Bound and Allocated is the whole criterion; Programmed is never set today")
+ assert.False(t, ready[pending.Name])
+}
+
+// TestReconcileInstanceGates_NetworkGatePerInstance is the regression test for
+// the readiness criterion. An instance whose claim is bound and allocated boots
+// even though Programmed — and therefore Ready — is still Unknown, and an
+// instance whose allocation is outstanding stays gated.
+func TestReconcileInstanceGates_NetworkGatePerInstance(t *testing.T) {
+ t.Parallel()
+
+ deployment := newClaimTestDeployment()
+ networkInterface := computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ }
+ allocated := newClaimTestInstance(claimTestDeployment+"-0", networkInterface)
+ pending := newClaimTestInstance(claimTestDeployment+"-1", networkInterface)
+
+ cl := fake.NewClientBuilder().
+ WithScheme(newClaimTestScheme()).
+ WithObjects(deployment, allocated, pending).
+ WithStatusSubresource(allocated, pending).
+ Build()
+
+ r := &WorkloadDeploymentReconciler{NetworkingEnabled: true}
+ _, _, _, _, _, err := r.reconcileInstanceGates(
+ context.Background(),
+ cl,
+ deployment,
+ []computev1alpha.Instance{*allocated, *pending},
+ map[string]bool{allocated.Name: true, pending.Name: false},
+ )
+ require.NoError(t, err)
+
+ gateNames := func(name string) []string {
+ var instance computev1alpha.Instance
+ require.NoError(t, cl.Get(context.Background(),
+ client.ObjectKey{Namespace: claimTestNamespace, Name: name}, &instance))
+ require.NotNil(t, instance.Spec.Controller)
+ names := make([]string, 0, len(instance.Spec.Controller.SchedulingGates))
+ for _, gate := range instance.Spec.Controller.SchedulingGates {
+ names = append(names, gate.Name)
+ }
+ return names
+ }
+
+ assert.Equal(t, []string{instancecontrol.QuotaSchedulingGate.String()}, gateNames(allocated.Name),
+ "the Network gate is released once the instance's own claims hold their addresses")
+ assert.Equal(t, []string{
+ instancecontrol.NetworkSchedulingGate.String(),
+ instancecontrol.QuotaSchedulingGate.String(),
+ }, gateNames(pending.Name),
+ "an instance whose allocation is outstanding stays gated")
+}
+
+// TestReconcileNetworkInterfaceStatus verifies the instance publishes the
+// addresses its claims hold, and that a second pass over unchanged claims
+// reports no change so the reconciler does not rewrite status on every event.
+func TestReconcileNetworkInterfaceStatus(t *testing.T) {
+ t.Parallel()
+
+ instance := newClaimTestInstance(claimTestDeployment+"-0",
+ computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ })
+
+ claim := &networkingv1alpha.NetworkInterfaceClaim{
+ ObjectMeta: metav1.ObjectMeta{Name: instance.Name + "-eth0", Namespace: claimTestNamespace},
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Addresses: []networkingv1alpha.NetworkInterfaceAddress{
+ {Family: networkingv1alpha.IPv4Protocol, Address: claimTestAddressCIDR, Primary: true},
+ },
+ ExternalAddresses: []networkingv1alpha.NetworkInterfaceExternalAddress{
+ {Family: networkingv1alpha.IPv4Protocol, Address: claimTestExternalIP, Class: claimTestClass},
+ },
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionTrue, "Allocated"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimProgrammed, metav1.ConditionUnknown, "Pending"),
+ },
+ },
+ }
+
+ cl := fake.NewClientBuilder().
+ WithScheme(newClaimTestScheme()).
+ WithObjects(instance, claim).
+ Build()
+
+ r := &InstanceReconciler{NetworkingEnabled: true}
+
+ changed, err := r.reconcileNetworkInterfaceStatus(context.Background(), cl, instance)
+ require.NoError(t, err)
+ require.True(t, changed)
+
+ require.Len(t, instance.Status.NetworkInterfaces, 1)
+ published := instance.Status.NetworkInterfaces[0]
+ assert.Equal(t, defaultInterfaceName, published.Name)
+ require.NotNil(t, published.Assignments.NetworkIP)
+ assert.Equal(t, claimTestAddress, *published.Assignments.NetworkIP)
+ require.NotNil(t, published.Assignments.ExternalIP)
+ assert.Equal(t, claimTestExternalIP, *published.Assignments.ExternalIP)
+ assert.Len(t, published.Conditions, 2)
+
+ changed, err = r.reconcileNetworkInterfaceStatus(context.Background(), cl, instance)
+ require.NoError(t, err)
+ assert.False(t, changed, "an unchanged claim must not rewrite the instance status")
+}
+
+// TestReconcileNetworkInterfaceStatus_NetworkingDisabled verifies nothing is
+// read or published on a cell without the networking integration, where the
+// claim CRD is absent.
+func TestReconcileNetworkInterfaceStatus_NetworkingDisabled(t *testing.T) {
+ t.Parallel()
+
+ instance := newClaimTestInstance(claimTestDeployment+"-0",
+ computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ })
+
+ r := &InstanceReconciler{}
+ changed, err := r.reconcileNetworkInterfaceStatus(context.Background(), nil, instance)
+ require.NoError(t, err)
+ assert.False(t, changed)
+ assert.Empty(t, instance.Status.NetworkInterfaces)
+}
+
+// TestCheckForNetworkCreationFailure_SurfacesClaimRejection verifies a refused
+// claim reaches the instance with NSO's own reason, and that a claim still
+// waiting is not reported as a failure.
+func TestCheckForNetworkCreationFailure_SurfacesClaimRejection(t *testing.T) {
+ t.Parallel()
+
+ instance := newClaimTestInstance(claimTestDeployment+"-0",
+ computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ Name: defaultInterfaceName,
+ })
+
+ rejected := &networkingv1alpha.NetworkInterfaceClaim{
+ ObjectMeta: metav1.ObjectMeta{Name: instance.Name + "-eth0", Namespace: claimTestNamespace},
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ {
+ Type: networkingv1alpha.NetworkInterfaceClaimBound,
+ Status: metav1.ConditionFalse,
+ Reason: "NetworkNotFound",
+ Message: `Network "default" was not found`,
+ },
+ },
+ },
+ }
+
+ cl := fake.NewClientBuilder().
+ WithScheme(newClaimTestScheme()).
+ WithObjects(instance, rejected).
+ Build()
+
+ r := &InstanceReconciler{NetworkingEnabled: true}
+ failed, message, err := r.checkForNetworkCreationFailure(context.Background(), cl, instance)
+ require.NoError(t, err)
+ assert.True(t, failed)
+ assert.Contains(t, message, "NetworkNotFound", "NSO's reason must reach the user unaltered")
+ assert.Contains(t, message, `Network "default" was not found`)
+
+ // A claim that has not been created yet is a wait, not a failure.
+ empty := fake.NewClientBuilder().WithScheme(newClaimTestScheme()).WithObjects(instance).Build()
+ failed, _, err = r.checkForNetworkCreationFailure(context.Background(), empty, instance)
+ require.NoError(t, err)
+ assert.False(t, failed)
+}
diff --git a/internal/controller/networkinterfaceclaim_test.go b/internal/controller/networkinterfaceclaim_test.go
new file mode 100644
index 00000000..d1323f56
--- /dev/null
+++ b/internal/controller/networkinterfaceclaim_test.go
@@ -0,0 +1,279 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+// TestNetworkInterfaceClaimName covers the name a claim is derived from. The
+// name identifies the instance slot, so it must be stable, and it must remain a
+// valid DNS subdomain even when the instance name alone approaches the limit.
+func TestNetworkInterfaceClaimName(t *testing.T) {
+ t.Parallel()
+
+ t.Run("joins the instance and interface names", func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "my-wd-0-eth0", networkInterfaceClaimName("my-wd-0", defaultInterfaceName))
+ })
+
+ t.Run("is stable across calls", func(t *testing.T) {
+ t.Parallel()
+ long := strings.Repeat("a", 250)
+ assert.Equal(t, networkInterfaceClaimName(long, "eth1"), networkInterfaceClaimName(long, "eth1"))
+ })
+
+ t.Run("truncates and hashes when the joined name is too long", func(t *testing.T) {
+ t.Parallel()
+
+ long := strings.Repeat("a", 250)
+ name := networkInterfaceClaimName(long, defaultInterfaceName)
+
+ assert.LessOrEqual(t, len(name), maxObjectNameLength)
+ assert.Empty(t, validation.IsDNS1123Subdomain(name),
+ "the fallback must still produce a valid DNS subdomain")
+ assert.NotEqual(t, long+"-"+defaultInterfaceName, name)
+ })
+
+ t.Run("distinguishes interfaces of the same over-long instance name", func(t *testing.T) {
+ t.Parallel()
+
+ long := strings.Repeat("a", 250)
+ assert.NotEqual(t, networkInterfaceClaimName(long, defaultInterfaceName), networkInterfaceClaimName(long, "eth1"),
+ "two interfaces of one instance must not collide on the same claim")
+ })
+
+ t.Run("does not leave a trailing separator after truncation", func(t *testing.T) {
+ t.Parallel()
+
+ // An instance name whose truncation point lands on a separator would
+ // otherwise yield "...--".
+ name := networkInterfaceClaimName(strings.Repeat("a", 239)+"-"+strings.Repeat("b", 20), defaultInterfaceName)
+ assert.Empty(t, validation.IsDNS1123Subdomain(name))
+ })
+}
+
+// TestNetworkIPProjection pins the rule for the single address clients read as
+// the instance's IP: host prefixes are reported bare, and anything shorter is
+// reported as the CIDR it is, because no single address in a delegated block is
+// the instance's.
+func TestNetworkIPProjection(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ address string
+ want string
+ }{
+ {"IPv4 host prefix is stripped", claimTestAddressCIDR, claimTestAddress},
+ {"IPv6 host prefix is stripped", "2001:db8:a001::1/128", "2001:db8:a001::1"},
+ {"IPv6 delegated block keeps its prefix", "2001:db8:a001::/96", "2001:db8:a001::/96"},
+ {"IPv4 subnet prefix keeps its prefix", "10.128.0.0/24", "10.128.0.0/24"},
+ {"a bare address passes through", "10.128.0.2", "10.128.0.2"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, networkIPProjection(tc.address))
+ })
+ }
+}
+
+// TestDesiredNetworkInterfaceClaimSpec verifies the interface request is copied
+// onto the claim verbatim, and that the fields the instance must not decide —
+// the bound interface, the location — are left for NSO.
+func TestDesiredNetworkInterfaceClaimSpec(t *testing.T) {
+ t.Parallel()
+
+ spec := desiredNetworkInterfaceClaimSpec(computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Namespace: "other-namespace", Name: claimTestNetwork},
+ Name: "eth1",
+ IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol, networkingv1alpha.IPv4Protocol},
+ ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyRetain,
+ Addresses: []computev1alpha.InstanceNetworkInterfaceAddressRequest{
+ {Class: claimTestClass},
+ },
+ })
+
+ assert.Equal(t, claimTestNetwork, spec.Network.Name)
+ assert.Equal(t, "eth1", spec.InterfaceName)
+ assert.Equal(t, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol, networkingv1alpha.IPv4Protocol}, spec.IPFamilies)
+ assert.Equal(t, networkingv1alpha.NetworkInterfaceReclaimPolicyRetain, spec.ReclaimPolicy)
+ require.Len(t, spec.Addresses, 1)
+ assert.Equal(t, claimTestClass, spec.Addresses[0].Class)
+ assert.Empty(t, spec.NetworkInterfaceName,
+ "the claim must bind the interface of its own name so a retained one is reused")
+
+ defaulted := desiredNetworkInterfaceClaimSpec(computev1alpha.InstanceNetworkInterface{
+ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork},
+ })
+ assert.Equal(t, defaultInterfaceName, defaulted.InterfaceName)
+}
+
+// TestNetworkInterfaceClaimSatisfied is the regression guard for the readiness
+// criterion: Programmed is seeded Unknown and no component sets it today, so
+// waiting on it (or on the Ready condition that summarizes it) would hold every
+// instance back forever.
+func TestNetworkInterfaceClaimSatisfied(t *testing.T) {
+ t.Parallel()
+
+ t.Run("bound and allocated is enough, with Programmed still Unknown", func(t *testing.T) {
+ t.Parallel()
+
+ claim := &networkingv1alpha.NetworkInterfaceClaim{
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionTrue, "Allocated"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimProgrammed, metav1.ConditionUnknown, "Pending"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimReady, metav1.ConditionUnknown, "NotProgrammed"),
+ },
+ },
+ }
+
+ assert.True(t, networkInterfaceClaimSatisfied(claim))
+ })
+
+ t.Run("not satisfied while allocation is outstanding", func(t *testing.T) {
+ t.Parallel()
+
+ claim := &networkingv1alpha.NetworkInterfaceClaim{
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionFalse, "AddressPoolExhausted"),
+ },
+ },
+ }
+
+ assert.False(t, networkInterfaceClaimSatisfied(claim))
+ })
+
+ t.Run("not satisfied before the controller reports anything", func(t *testing.T) {
+ t.Parallel()
+ assert.False(t, networkInterfaceClaimSatisfied(&networkingv1alpha.NetworkInterfaceClaim{}))
+ })
+}
+
+// TestNetworkInterfaceClaimRejection verifies the refusal reason reaches the
+// caller verbatim, and that a claim merely waiting reports no refusal.
+func TestNetworkInterfaceClaimRejection(t *testing.T) {
+ t.Parallel()
+
+ claim := &networkingv1alpha.NetworkInterfaceClaim{
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionFalse, "NetworkNotFound"),
+ },
+ },
+ }
+ reason, message := networkInterfaceClaimRejection(claim)
+ assert.Equal(t, "NetworkNotFound", reason)
+ assert.NotEmpty(t, message)
+
+ pending := &networkingv1alpha.NetworkInterfaceClaim{
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionUnknown, "Pending"),
+ },
+ },
+ }
+ reason, _ = networkInterfaceClaimRejection(pending)
+ assert.Empty(t, reason, "a claim that has not been answered yet is not a refusal")
+}
+
+// TestInstanceNetworkInterfaceStatus verifies the projection onto the instance:
+// addresses and external addresses are carried across, the primary address
+// feeds the single-address networkIP field, and only the conditions describing
+// the interface itself are mirrored.
+func TestInstanceNetworkInterfaceStatus(t *testing.T) {
+ t.Parallel()
+
+ claim := &networkingv1alpha.NetworkInterfaceClaim{
+ Status: networkingv1alpha.NetworkInterfaceClaimStatus{
+ Addresses: []networkingv1alpha.NetworkInterfaceAddress{
+ {
+ Family: networkingv1alpha.IPv6Protocol,
+ Address: "2001:db8:a001::1/128",
+ Gateway: "2001:db8:a001::",
+ Primary: true,
+ },
+ {
+ Family: networkingv1alpha.IPv4Protocol,
+ Address: claimTestAddressCIDR,
+ Class: "private-ipv4",
+ },
+ },
+ ExternalAddresses: []networkingv1alpha.NetworkInterfaceExternalAddress{
+ {Family: networkingv1alpha.IPv4Protocol, Address: claimTestExternalIP, Class: claimTestClass},
+ },
+ Conditions: []metav1.Condition{
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimBound, metav1.ConditionTrue, "Bound"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimAllocated, metav1.ConditionTrue, "Allocated"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimProgrammed, metav1.ConditionUnknown, "Pending"),
+ claimCondition(networkingv1alpha.NetworkInterfaceClaimReady, metav1.ConditionUnknown, "NotProgrammed"),
+ },
+ },
+ }
+
+ status := instanceNetworkInterfaceStatus(defaultInterfaceName, claim)
+
+ assert.Equal(t, defaultInterfaceName, status.Name)
+ require.Len(t, status.Addresses, 2)
+ assert.Equal(t, "2001:db8:a001::1/128", status.Addresses[0].Address,
+ "the interface addresses keep their prefix length")
+ assert.Equal(t, "2001:db8:a001::", status.Addresses[0].Gateway)
+ require.Len(t, status.ExternalAddresses, 1)
+
+ require.NotNil(t, status.Assignments.NetworkIP)
+ assert.Equal(t, "2001:db8:a001::1", *status.Assignments.NetworkIP,
+ "networkIP projects the primary address, bare")
+ require.NotNil(t, status.Assignments.ExternalIP)
+ assert.Equal(t, claimTestExternalIP, *status.Assignments.ExternalIP)
+
+ conditionTypes := make([]string, 0, len(status.Conditions))
+ for _, condition := range status.Conditions {
+ conditionTypes = append(conditionTypes, condition.Type)
+ assert.Zero(t, condition.ObservedGeneration,
+ "the claim's generation says nothing about the instance")
+ }
+ assert.Equal(t, []string{
+ computev1alpha.InstanceNetworkInterfaceAllocated,
+ computev1alpha.InstanceNetworkInterfaceProgrammed,
+ }, conditionTypes)
+}
+
+// TestInstanceNetworkInterfaceStatus_NoClaim verifies an interface whose claim
+// does not exist yet is still reported by name, so the status has the shape of
+// the spec from the start.
+func TestInstanceNetworkInterfaceStatus_NoClaim(t *testing.T) {
+ t.Parallel()
+
+ status := instanceNetworkInterfaceStatus(defaultInterfaceName, nil)
+
+ assert.Equal(t, defaultInterfaceName, status.Name)
+ assert.Empty(t, status.Addresses)
+ assert.Nil(t, status.Assignments.NetworkIP)
+}
+
+// claimCondition builds a claim status condition with a message, mirroring the
+// shape NSO writes.
+func claimCondition(conditionType string, status metav1.ConditionStatus, reason string) metav1.Condition {
+ return metav1.Condition{
+ Type: conditionType,
+ Status: status,
+ Reason: reason,
+ Message: "condition message for " + conditionType,
+ LastTransitionTime: metav1.Now(),
+ }
+}
diff --git a/internal/controller/workload_controller.go b/internal/controller/workload_controller.go
index 01579df4..562df4a8 100644
--- a/internal/controller/workload_controller.go
+++ b/internal/controller/workload_controller.go
@@ -43,6 +43,12 @@ const (
type WorkloadReconciler struct {
mgr mcmanager.Manager
finalizers finalizer.Finalizers
+
+ // NetworkingEnabled mirrors the NetworkingIntegration feature gate. When
+ // false the Network watch is not registered: the networking CRDs are absent
+ // on control planes without the integration, and engaging a watch against a
+ // missing kind wedges the manager.
+ NetworkingEnabled bool
}
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloads,verbs=get;list;watch;create;update;patch;delete
@@ -532,9 +538,15 @@ func (r *WorkloadReconciler) SetupWithManager(mgr mcmanager.Manager) error {
return fmt.Errorf("failed to register finalizer: %w", err)
}
- return mcbuilder.ControllerManagedBy(mgr).
+ b := mcbuilder.ControllerManagedBy(mgr).
For(&computev1alpha.Workload{}, mcbuilder.WithEngageWithLocalCluster(false)).
- Owns(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)).
+ Owns(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false))
+
+ if !r.NetworkingEnabled {
+ return b.Complete(r)
+ }
+
+ return b.
Watches(&networkingv1alpha.Network{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, network client.Object) []mcreconcile.Request {
logger := log.FromContext(ctx)
diff --git a/internal/controller/workloaddeployment_controller.go b/internal/controller/workloaddeployment_controller.go
index 152a6952..b435c758 100644
--- a/internal/controller/workloaddeployment_controller.go
+++ b/internal/controller/workloaddeployment_controller.go
@@ -49,8 +49,8 @@ type WorkloadDeploymentReconciler struct {
finalizers finalizer.Finalizers
// NetworkingEnabled controls whether the networking integration with
- // network-services-operator is active. When false, NetworkBinding creation is
- // skipped, the Network scheduling gate is never added to Instances (and is
+ // network-services-operator is active. When false, interface claim creation
+ // is skipped, the Network scheduling gate is never added to Instances (and is
// actively removed if present), and the networking step is treated as
// immediately ready. Defaults to false.
NetworkingEnabled bool
@@ -80,13 +80,11 @@ func workloadDeploymentPodSelector(deployment *computev1alpha.WorkloadDeployment
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments/finalizers,verbs=update
// +kubebuilder:rbac:groups=networking.datumapis.com,resources=locations,verbs=get;list;watch
-// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkbindings,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts,verbs=get;list;watch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces,verbs=get;list;watch
// The management-mode WorkloadReconciler watches Networks. Declare the grant as
// a marker so regenerating the role keeps it.
// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networks,verbs=get;list;watch
-// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnetclaims,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch
func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
@@ -176,33 +174,40 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
}
// When networking is disabled, bypass the entire network provisioning path.
- // The Network scheduling gate is treated as cleared and no NetworkBindings
- // are created. This lets Instances reach the runtime on cells where
- // network-services-operator (VPC) is not yet available.
- var networkReady bool
+ // The Network scheduling gate is treated as cleared for every instance and no
+ // interface claims are created. This lets Instances reach the runtime on cells
+ // where network-services-operator (VPC) is not yet available.
+ networkReadyByInstance := make(map[string]bool, len(instances.Items))
locationResolved := true
if !r.NetworkingEnabled {
- networkReady = true
+ for _, instance := range instances.Items {
+ networkReadyByInstance[instance.Name] = true
+ }
} else {
- var resolvedLocation *networkingv1alpha.LocationReference
- networkReady, resolvedLocation, err = r.reconcileNetworks(ctx, cl.GetClient(), &deployment)
+ resolvedLocation, err := r.resolveLocation(ctx, cl.GetClient(), &deployment)
if err != nil {
- return ctrl.Result{}, fmt.Errorf("failed reconciling networks: %w", err)
+ return ctrl.Result{}, fmt.Errorf("failed resolving location: %w", err)
}
// Persist the resolved Location to status so downstream components (e.g.
// the stateful instance control strategy) can propagate it to Instances.
// When no matching Location exists, resolvedLocation is nil and
- // Status.Location remains nil — instance creation is not blocked.
+ // Status.Location remains nil — instance creation is not blocked, and
+ // interface claims do not depend on it: a claim is served by the control
+ // plane it is created in, which is already location scoped.
locationResolved = resolvedLocation != nil
if resolvedLocation != nil {
deployment.Status.Location = resolvedLocation
}
+
+ networkReadyByInstance, err = r.reconcileNetworkInterfaceClaims(ctx, cl.GetClient(), &deployment, instances.Items)
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed reconciling network interface claims: %w", err)
+ }
}
- // Networks are all ready with subnets ready to use, remove any scheduling
- // gates on instances. If any instances were created by actions above, those
- // will result in this reconciler being processed again, so will properly have
- // their gates removed.
+ // Each instance's Network scheduling gate is released as soon as its own
+ // claims hold their addresses. Instances created by the actions above are not
+ // in this pass's list; the Instance watch re-runs this reconcile for them.
// Translate the suspend/resume request carried on SuspendedAnnotation
// (written hub-side by the ComputeSuspend/ComputeResume consumer hooks and
@@ -217,11 +222,22 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
replicas := len(instances.Items)
- currentReplicas, updatedReplicas, readyReplicas, quotaBlockedReplicas, referencedDataBlockedReplicas, err := r.reconcileInstanceGates(ctx, cl.GetClient(), &deployment, instances.Items, networkReady)
+ currentReplicas, updatedReplicas, readyReplicas, quotaBlockedReplicas, referencedDataBlockedReplicas, err := r.reconcileInstanceGates(ctx, cl.GetClient(), &deployment, instances.Items, networkReadyByInstance)
if err != nil {
return ctrl.Result{}, err
}
+ // The deployment reports one networking state to users even though the gate
+ // is now per instance: it is still waiting on the network while any instance
+ // is.
+ networkReady := true
+ for _, instance := range instances.Items {
+ if !networkReadyByInstance[instance.Name] {
+ networkReady = false
+ break
+ }
+ }
+
deployment.Status.Replicas = int32(replicas)
deployment.Status.CurrentReplicas = int32(currentReplicas)
deployment.Status.UpdatedReplicas = int32(updatedReplicas)
@@ -288,7 +304,7 @@ func (r *WorkloadDeploymentReconciler) reconcileInstanceGates(
c client.Client,
deployment *computev1alpha.WorkloadDeployment,
instances []computev1alpha.Instance,
- networkReady bool,
+ networkReadyByInstance map[string]bool,
) (currentReplicas, updatedReplicas, readyReplicas, quotaBlockedReplicas, referencedDataBlockedReplicas int, err error) {
templateHash := instancecontrol.ComputeHash(deployment.Spec.Template)
for _, instance := range instances {
@@ -309,10 +325,13 @@ func (r *WorkloadDeploymentReconciler) reconcileInstanceGates(
referencedDataBlockedReplicas++
}
+ // The gate is released per instance: an instance whose own interface claims
+ // hold their addresses boots even while a sibling's are still pending.
+ //
// Spec.Controller is a nilable pointer; guard it before dereferencing the
// scheduling gates so an instance without controller state cannot panic
// the reconcile (mirrors the Status.Controller guard below).
- if networkReady && instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0 {
+ if networkReadyByInstance[instance.Name] && instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0 {
newGates := slices.DeleteFunc(instance.Spec.Controller.SchedulingGates, func(gate computev1alpha.SchedulingGate) bool {
return gate.Name == instancecontrol.NetworkSchedulingGate.String()
})
@@ -461,17 +480,17 @@ func selectWDBlockingCondition(
}
}
+ // An unresolved city is the only user-visible signal that instances are
+ // running without the Location their placement was asked for, and it is
+ // considered first so it wins over the generic provisioning reason they
+ // share a priority with.
+ if !locationResolved {
+ consider(computev1alpha.WorkloadDeploymentReasonNoMatchingLocation,
+ fmt.Sprintf("No Location matches city code %q", deployment.Spec.CityCode))
+ }
+
if !networkReady {
- if !locationResolved {
- // Network provisioning cannot even start without a Location, so
- // surface the unresolved city rather than the generic provisioning
- // reason — it is the only user-visible signal while the deployment
- // waits for the city's Location to be created.
- consider(computev1alpha.WorkloadDeploymentReasonNoMatchingLocation,
- fmt.Sprintf("No Location matches city code %q", deployment.Spec.CityCode))
- } else {
- consider(computev1alpha.WorkloadDeploymentReasonNetworkProvisioning, "Network is being provisioned")
- }
+ consider(computev1alpha.WorkloadDeploymentReasonNetworkProvisioning, "Network is being provisioned")
}
// WD-level ReferencedDataReady condition reflects the resolver verdict; when
@@ -540,9 +559,9 @@ func selectWDBlockingCondition(
//
// 0 - unknown/default
// 1 - InstancesProvisioning (transient startup)
-// 2 - NetworkProvisioning / NoMatchingLocation (infra provisioning; the two
-// are mutually exclusive — NoMatchingLocation is considered only while
-// the city's Location is unresolved, before provisioning can start)
+// 2 - NetworkProvisioning / NoMatchingLocation (infra provisioning; when both
+// apply the unresolved city wins, because it names something an operator
+// can act on)
// 3 - QuotaNotGranted (operator action may be needed)
// 4 - ReferencedDataNotReady (AwaitingPropagation / Resolving — expected to clear)
// 5 - SourceNotFound / SourceTooLarge / SourceUnauthorized (hard spec error)
@@ -575,211 +594,144 @@ func wdBlockingReasonPriority(reason string) int {
}
}
-// reconcileNetworks ensures NetworkBindings and SubnetClaims exist for all
-// network interfaces on the deployment. It returns (networkReady, resolvedLocation, err).
-// resolvedLocation is non-nil when a Location matching the deployment's city code
-// was found; nil otherwise. Instance creation is never gated on resolvedLocation
-// being non-nil — callers must treat a nil location as best-effort only.
-func (r *WorkloadDeploymentReconciler) reconcileNetworks(
+// resolveLocation returns the Location matching the deployment's city code, or
+// nil when the city has no Location yet. It is reported to users on the
+// Available condition and persisted to status so instances carry it, but
+// nothing is gated on it: interface claims are served by the control plane they
+// are created in, which is already location scoped.
+func (r *WorkloadDeploymentReconciler) resolveLocation(
ctx context.Context,
c client.Client,
deployment *computev1alpha.WorkloadDeployment,
-) (bool, *networkingv1alpha.LocationReference, error) {
- logger := log.FromContext(ctx)
-
- // Resolve the Location for this deployment's city code. With Karmada
- // propagation the WorkloadDeployment lands in the cluster that serves the
- // requested city, so the Location object for that city must exist locally.
+) (*networkingv1alpha.LocationReference, error) {
+ // With Karmada propagation the WorkloadDeployment lands in the cluster that
+ // serves the requested city, so the Location object for that city must exist
+ // locally.
var locationList networkingv1alpha.LocationList
if err := c.List(ctx, &locationList); err != nil {
- return false, nil, fmt.Errorf("failed to list locations: %w", err)
+ return nil, fmt.Errorf("failed to list locations: %w", err)
}
- var locationRef *networkingv1alpha.LocationReference
- for _, loc := range locationList.Items {
- if cityCode, ok := loc.Spec.Topology["topology.datum.net/city-code"]; ok && cityCode == deployment.Spec.CityCode {
- locationRef = &networkingv1alpha.LocationReference{
- Name: loc.Name,
- Namespace: loc.Namespace,
- }
- break
+ for _, location := range locationList.Items {
+ if cityCode, ok := location.Spec.Topology["topology.datum.net/city-code"]; ok && cityCode == deployment.Spec.CityCode {
+ return &networkingv1alpha.LocationReference{
+ Name: location.Name,
+ Namespace: location.Namespace,
+ }, nil
}
}
- if locationRef == nil {
- // Surfaced to users via the Available condition (NoMatchingLocation); the
- // log is debug-level detail only.
- logger.V(1).Info("no location found for city code, waiting", "cityCode", deployment.Spec.CityCode)
- return false, nil, nil
- }
-
- // First, ensure we have a NetworkBinding for each interface, and that the
- // binding is ready before we move on to create SubnetClaims.
+ // Surfaced to users via the Available condition (NoMatchingLocation); the log
+ // is debug-level detail only.
+ log.FromContext(ctx).V(1).Info("no location found for city code, waiting", "cityCode", deployment.Spec.CityCode)
+ return nil, nil
+}
- var networkContextRefs []networkingv1alpha.NetworkContextRef
- allNetworkBindingsReady := true
- for i, networkInterface := range deployment.Spec.Template.Spec.NetworkInterfaces {
- var networkBinding networkingv1alpha.NetworkBinding
- networkBindingObjectKey := client.ObjectKey{
- Namespace: deployment.Namespace,
- Name: fmt.Sprintf("%s-net-%d", deployment.Name, i),
- }
+// reconcileNetworkInterfaceClaims ensures one NetworkInterfaceClaim exists per
+// instance interface, and reports which instances hold every address they asked
+// for. The returned map is keyed by instance name; an instance missing from it,
+// or mapped to false, is still waiting on its addresses.
+//
+// A claim names the instance slot rather than the instance object, and is owned
+// by the instance filling that slot. Deleting the instance therefore releases
+// the claim, at which point the interface's reclaim policy decides whether the
+// addresses are returned to IPAM or held for the next instance in the slot.
+func (r *WorkloadDeploymentReconciler) reconcileNetworkInterfaceClaims(
+ ctx context.Context,
+ c client.Client,
+ deployment *computev1alpha.WorkloadDeployment,
+ instances []computev1alpha.Instance,
+) (map[string]bool, error) {
+ logger := log.FromContext(ctx)
- if err := c.Get(ctx, networkBindingObjectKey, &networkBinding); client.IgnoreNotFound(err) != nil {
- return false, nil, fmt.Errorf("failed checking for existing network binding: %w", err)
+ readyByInstance := make(map[string]bool, len(instances))
+ for i := range instances {
+ instance := &instances[i]
+ if !instance.DeletionTimestamp.IsZero() {
+ continue
}
- if networkBinding.CreationTimestamp.IsZero() {
- networkBinding = networkingv1alpha.NetworkBinding{
- ObjectMeta: metav1.ObjectMeta{
- Namespace: networkBindingObjectKey.Namespace,
- Name: networkBindingObjectKey.Name,
- },
- Spec: networkingv1alpha.NetworkBindingSpec{
- Network: networkInterface.Network,
- Location: *locationRef,
- },
- }
-
- if err := controllerutil.SetControllerReference(deployment, &networkBinding, c.Scheme()); err != nil {
- return false, nil, fmt.Errorf("failed to set controller on network binding: %w", err)
+ ready := true
+ for _, networkInterface := range instance.Spec.NetworkInterfaces {
+ claim, err := r.ensureNetworkInterfaceClaim(ctx, c, deployment, instance, networkInterface)
+ if err != nil {
+ return nil, err
}
- if err := c.Create(ctx, &networkBinding); err != nil {
- return false, nil, fmt.Errorf("failed creating network binding: %w", err)
+ if !networkInterfaceClaimSatisfied(claim) {
+ ready = false
+ if reason, message := networkInterfaceClaimRejection(claim); reason != "" {
+ // The rejection is surfaced to users on the Instance by the
+ // instance reconciler; log it here so an operator watching the
+ // deployment sees why it is stuck.
+ logger.Info("network interface claim cannot be fulfilled",
+ "claim", claim.Name, "reason", reason, "message", message)
+ }
}
}
-
- if !apimeta.IsStatusConditionTrue(networkBinding.Status.Conditions, networkingv1alpha.NetworkBindingReady) {
- allNetworkBindingsReady = false
- } else if networkBinding.Status.NetworkContextRef != nil {
- networkContextRefs = append(networkContextRefs, *networkBinding.Status.NetworkContextRef)
- }
+ readyByInstance[instance.Name] = ready
}
- if !allNetworkBindingsReady {
- logger.Info("waiting for network bindings to be ready")
- return false, locationRef, nil
- }
-
- // TODO(jreese): Currently this makes a SubnetClaim that will be used by
- // many instances. Move to a claim per instance interface, and allocate from
- // a larger subnet. In addition, it does not handle allocation of more than
- // one subnet per network context. We'll have a future IPAM controller in
- // network-services-operator that will handle this.
- //
- // Also, only handling ipv4
-
- for _, networkContextRef := range networkContextRefs {
- var networkContext networkingv1alpha.NetworkContext
- networkContextObjectKey := client.ObjectKey{
- Namespace: networkContextRef.Namespace,
- Name: networkContextRef.Name,
- }
-
- if err := c.Get(ctx, networkContextObjectKey, &networkContext); client.IgnoreNotFound(err) != nil {
- return false, nil, fmt.Errorf("failed checking for existing network context: %w", err)
- }
-
- if !apimeta.IsStatusConditionTrue(networkContext.Status.Conditions, networkingv1alpha.NetworkContextReady) {
- logger.Info("waiting for network context to be ready", "network_context", networkContext.Name)
- return false, locationRef, nil
- }
-
- var subnetClaims networkingv1alpha.SubnetClaimList
- listOpts := []client.ListOption{
- client.InNamespace(networkContext.Namespace),
- }
-
- if err := c.List(ctx, &subnetClaims, listOpts...); err != nil {
- return false, nil, fmt.Errorf("failed listing subnet claims: %w", err)
- }
-
- var subnetClaim networkingv1alpha.SubnetClaim
- for _, claim := range subnetClaims.Items {
- // If it's not the same subnet class, don't consider the subnet claim.
- if claim.Spec.SubnetClass != "private" {
- continue
- }
-
- // If it's not ipv4, don't consider the subnet claim.
- if claim.Spec.IPFamily != networkingv1alpha.IPv4Protocol {
- continue
- }
-
- // If it's not the same network context, don't consider the subnet claim.
- if claim.Spec.NetworkContext.Name != networkContext.Name {
- continue
- }
+ return readyByInstance, nil
+}
- // If it's not the same location, don't consider the subnet claim.
- if claim.Spec.Location.Namespace != locationRef.Namespace ||
- claim.Spec.Location.Name != locationRef.Name {
- continue
- }
+// ensureNetworkInterfaceClaim creates the claim for one instance interface if it
+// is absent, and returns the claim either way.
+//
+// An existing claim is never updated: almost every field of a claim spec is
+// immutable, because the addresses were allocated against it. A changed
+// interface request is expressed by replacing the instance, which replaces the
+// claim with it.
+func (r *WorkloadDeploymentReconciler) ensureNetworkInterfaceClaim(
+ ctx context.Context,
+ c client.Client,
+ deployment *computev1alpha.WorkloadDeployment,
+ instance *computev1alpha.Instance,
+ networkInterface computev1alpha.InstanceNetworkInterface,
+) (*networkingv1alpha.NetworkInterfaceClaim, error) {
+ claim := &networkingv1alpha.NetworkInterfaceClaim{}
+ key := client.ObjectKey{
+ Namespace: deployment.Namespace,
+ Name: networkInterfaceClaimName(instance.Name, instanceInterfaceName(networkInterface)),
+ }
- subnetClaim = claim
- break
- }
+ err := c.Get(ctx, key, claim)
+ if err == nil {
+ return claim, nil
+ }
+ if !apierrors.IsNotFound(err) {
+ return nil, fmt.Errorf("failed checking for existing network interface claim: %w", err)
+ }
- if subnetClaim.CreationTimestamp.IsZero() {
- subnetClaim = networkingv1alpha.SubnetClaim{
- ObjectMeta: metav1.ObjectMeta{
- Namespace: networkContext.Namespace,
- // In the future, subnets will be created with an ordinal that increases.
- // This ensures that we don't create duplicate subnet claims when the
- // cache is not up to date.
- Name: fmt.Sprintf("%s-0", networkContext.Name),
- },
- Spec: networkingv1alpha.SubnetClaimSpec{
- SubnetClass: "private",
- IPFamily: networkingv1alpha.IPv4Protocol,
- NetworkContext: networkingv1alpha.LocalNetworkContextRef{
- Name: networkContext.Name,
- },
- Location: *locationRef,
- },
- }
+ claim = &networkingv1alpha.NetworkInterfaceClaim{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: key.Namespace,
+ Name: key.Name,
+ },
+ Spec: desiredNetworkInterfaceClaimSpec(networkInterface),
+ }
- if err := controllerutil.SetOwnerReference(&networkContext, &subnetClaim, c.Scheme()); err != nil {
- return false, nil, fmt.Errorf("failed to set controller on subnet claim: %w", err)
- }
+ // The claim belongs to the instance, not the deployment: the instance going
+ // away is what ends the slot's hold on the interface.
+ if err := controllerutil.SetControllerReference(instance, claim, c.Scheme()); err != nil {
+ return nil, fmt.Errorf("failed to set controller on network interface claim: %w", err)
+ }
- if err := c.Create(ctx, &subnetClaim); err != nil {
- return false, nil, fmt.Errorf("failed creating subnet claim: %w", err)
+ if err := c.Create(ctx, claim); err != nil {
+ if apierrors.IsAlreadyExists(err) {
+ // Lost a race with another writer, or with this controller's own stale
+ // cache. Read what is there rather than reporting a failure.
+ if getErr := c.Get(ctx, key, claim); getErr != nil {
+ return nil, fmt.Errorf("failed fetching network interface claim: %w", getErr)
}
-
- logger.Info("created subnet claim", "subnetClaim", subnetClaim.Name)
-
- return false, locationRef, nil
+ return claim, nil
}
-
- logger.Info("found subnet claim", "subnetClaim", subnetClaim.Name)
-
- if !apimeta.IsStatusConditionTrue(subnetClaim.Status.Conditions, "Ready") {
- logger.Info("waiting for subnet claim to be ready", "subnetClaim", subnetClaim.Name)
- return false, locationRef, nil
- }
-
- var subnet networkingv1alpha.Subnet
- subnetObjectKey := client.ObjectKey{
- Namespace: subnetClaim.Namespace,
- Name: subnetClaim.Status.SubnetRef.Name,
- }
- if err := c.Get(ctx, subnetObjectKey, &subnet); err != nil {
- return false, nil, fmt.Errorf("failed fetching subnet: %w", err)
- }
-
- if !apimeta.IsStatusConditionTrue(subnet.Status.Conditions, "Ready") {
- logger.Info("waiting for subnet to be ready", "subnet", subnet.Name)
- return false, locationRef, nil
- }
-
- logger.Info("subnet is ready", "subnet", subnet.Name)
-
+ return nil, fmt.Errorf("failed creating network interface claim: %w", err)
}
- return true, locationRef, nil
+ log.FromContext(ctx).Info("created network interface claim", "claim", claim.Name, "instance", instance.Name)
+
+ return claim, nil
}
func (r *WorkloadDeploymentReconciler) Finalize(_ context.Context, _ client.Object) (finalizer.Result, error) {
@@ -823,59 +775,65 @@ func (r *WorkloadDeploymentReconciler) SetupWithManager(mgr mcmanager.Manager, o
// errors for missing CRDs.
if r.NetworkingEnabled {
b = b.
- Owns(&networkingv1alpha.NetworkBinding{}).
+ // A claim becoming bound and allocated is what releases an instance's
+ // Network gate, and nothing else wakes this reconciler for it. The claim
+ // is owned by its Instance, which in turn is owned by the deployment, so
+ // the event is mapped up two levels.
+ Watches(&networkingv1alpha.NetworkInterfaceClaim{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
+ return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request {
+ return enqueueWorkloadDeploymentForClaim(ctx, cl.GetClient(), clusterName, o)
+ })
+ }).
// A deployment whose city has no Location yet waits without any other
- // wake-up event: NetworkBindings/SubnetClaims/Subnets only exist after
- // a Location resolved, and the reconciler does not poll. Watching
- // Locations re-reconciles the waiting deployments when their city's
- // Location appears (or its topology changes).
+ // wake-up event, and the reconciler does not poll. Watching Locations
+ // re-reconciles the waiting deployments when their city's Location
+ // appears (or its topology changes) so Status.Location is filled in.
Watches(&networkingv1alpha.Location{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request {
location := o.(*networkingv1alpha.Location)
return enqueueWorkloadDeploymentsForLocation(ctx, cl.GetClient(), clusterName, location)
})
- }).
- Watches(&networkingv1alpha.SubnetClaim{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
- return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request {
- subnetClaim := o.(*networkingv1alpha.SubnetClaim)
- return enqueueWorkloadDeploymentByLocation(ctx, mgr, clusterName, subnetClaim.Spec.Location)
- })
- }).
- Watches(&networkingv1alpha.Subnet{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
- return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request {
- subnet := o.(*networkingv1alpha.Subnet)
- return enqueueWorkloadDeploymentByLocation(ctx, mgr, clusterName, subnet.Spec.Location)
- })
})
}
return b.Complete(r)
}
-// enqueueWorkloadDeploymentByLocation maps an object that carries a
-// LocationReference (SubnetClaim, Subnet) to the WorkloadDeployments targeting
-// the referenced Location's city. The reference must be resolved to the Location
-// object first because only its topology carries the city code.
-func enqueueWorkloadDeploymentByLocation(ctx context.Context, mgr mcmanager.Manager, clusterName multicluster.ClusterName, locationRef networkingv1alpha.LocationReference) []mcreconcile.Request {
+// enqueueWorkloadDeploymentForClaim maps a NetworkInterfaceClaim to the
+// WorkloadDeployment that owns the Instance the claim was created for. Claims
+// created by anything else carry no Instance owner and map to nothing.
+func enqueueWorkloadDeploymentForClaim(ctx context.Context, c client.Client, clusterName multicluster.ClusterName, claim client.Object) []mcreconcile.Request {
logger := log.FromContext(ctx)
- cl, err := mgr.GetCluster(ctx, clusterName)
- if err != nil {
- logger.Error(err, "failed to get cluster")
+ owner := metav1.GetControllerOf(claim)
+ if owner == nil || owner.Kind != "Instance" {
+ return nil
+ }
+
+ var instance computev1alpha.Instance
+ if err := c.Get(ctx, types.NamespacedName{Namespace: claim.GetNamespace(), Name: owner.Name}, &instance); err != nil {
+ if !apierrors.IsNotFound(err) {
+ logger.Error(err, "failed to get instance for network interface claim", "claim", claim.GetName())
+ }
return nil
}
- clusterClient := cl.GetClient()
- var location networkingv1alpha.Location
- if err := clusterClient.Get(ctx, types.NamespacedName{
- Namespace: locationRef.Namespace,
- Name: locationRef.Name,
- }, &location); err != nil {
- logger.Error(err, "failed to get location for enqueue", "location", locationRef)
+ instanceOwner := metav1.GetControllerOf(&instance)
+ if instanceOwner == nil || instanceOwner.Kind != kindWorkloadDeployment {
return nil
}
- return enqueueWorkloadDeploymentsForLocation(ctx, clusterClient, clusterName, &location)
+ return []mcreconcile.Request{
+ {
+ Request: reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Namespace: instance.Namespace,
+ Name: instanceOwner.Name,
+ },
+ },
+ ClusterName: clusterName,
+ },
+ }
}
// enqueueWorkloadDeploymentsForLocation maps a Location to the
diff --git a/internal/controller/workloaddeployment_controller_test.go b/internal/controller/workloaddeployment_controller_test.go
index 9d90f938..d8944a3e 100644
--- a/internal/controller/workloaddeployment_controller_test.go
+++ b/internal/controller/workloaddeployment_controller_test.go
@@ -162,7 +162,7 @@ func TestReconcileInstanceGates_NilController_DoesNotPanic(t *testing.T) {
instanceReady,
}
- // Use a fake client. networkReady=false avoids the gate-patch path that
+ // Use a fake client. An empty readiness map avoids the gate-patch path that
// would call CreateOrPatch, so the client is not exercised here.
cl := newProjectFakeClient()
r := &WorkloadDeploymentReconciler{}
@@ -173,7 +173,7 @@ func TestReconcileInstanceGates_NilController_DoesNotPanic(t *testing.T) {
cl,
deployment,
instances,
- false, // networkReady=false: skip gate-patch path
+ nil, // no instance holds its addresses: skip gate-patch path
)
require.NoError(t, err)
@@ -194,8 +194,8 @@ func TestReconcileInstanceGates_NilController_DoesNotPanic(t *testing.T) {
// TestReconcileInstanceGates_NilSpecController_DoesNotPanic is a regression test
// for a nil-deref in reconcileInstanceGates: Spec.Controller is a nilable
// pointer, and the network gate-clearing path dereferenced
-// instance.Spec.Controller.SchedulingGates without a nil guard. When
-// networkReady is true and an instance has no controller spec, the unguarded
+// instance.Spec.Controller.SchedulingGates without a nil guard. When the
+// instance's claims are satisfied and it has no controller spec, the unguarded
// deref panicked the reconcile. This must not panic.
func TestReconcileInstanceGates_NilSpecController_DoesNotPanic(t *testing.T) {
t.Parallel()
@@ -212,7 +212,8 @@ func TestReconcileInstanceGates_NilSpecController_DoesNotPanic(t *testing.T) {
}
// Spec.Controller intentionally nil — the network gate-clearing path runs
- // (networkReady=true) and must skip this instance instead of panicking.
+ // (the instance is reported network-ready) and must skip this instance
+ // instead of panicking.
instanceNilSpecController := computev1alpha.Instance{
ObjectMeta: metav1.ObjectMeta{Name: "instance-nil-spec-controller", Namespace: wdControllerTestNS},
}
@@ -226,7 +227,7 @@ func TestReconcileInstanceGates_NilSpecController_DoesNotPanic(t *testing.T) {
cl,
deployment,
[]computev1alpha.Instance{instanceNilSpecController},
- true, // networkReady=true exercises the Spec.Controller deref path
+ map[string]bool{instanceNilSpecController.Name: true}, // exercises the Spec.Controller deref path
)
require.NoError(t, err)
})
@@ -295,7 +296,7 @@ func TestReconcileInstanceGates_ReplicaCounting(t *testing.T) {
cl,
deployment,
[]computev1alpha.Instance{instanceUpdatedReady, instanceStale, instanceUpdatedPending, instanceQuotaBlocked},
- false,
+ nil,
)
require.NoError(t, err)
@@ -339,7 +340,7 @@ func TestReconcileInstanceGates_ClearsNetworkSchedulingGate(t *testing.T) {
cl,
deployment,
[]computev1alpha.Instance{*instance},
- true,
+ map[string]bool{instance.Name: true},
)
require.NoError(t, err)
@@ -363,7 +364,7 @@ func TestReconcileInstanceGates_ClearsNetworkSchedulingGate(t *testing.T) {
cl,
deployment,
[]computev1alpha.Instance{*instance},
- false,
+ nil,
)
require.NoError(t, err)
diff --git a/internal/controller/workloaddeployment_hpa_controller.go b/internal/controller/workloaddeployment_hpa_controller.go
index b1cd0aff..be20c84f 100644
--- a/internal/controller/workloaddeployment_hpa_controller.go
+++ b/internal/controller/workloaddeployment_hpa_controller.go
@@ -85,7 +85,7 @@ func (r *WorkloadDeploymentHPAReconciler) Reconcile(ctx context.Context, req mcr
hpa.Spec = autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: computev1alpha.GroupVersion.String(),
- Kind: "WorkloadDeployment",
+ Kind: kindWorkloadDeployment,
Name: deployment.Name,
},
MinReplicas: new(deployment.Spec.ScaleSettings.MinReplicas),
diff --git a/internal/controller/workloaddeployment_location_test.go b/internal/controller/workloaddeployment_location_test.go
index d8b6aa8e..5337824c 100644
--- a/internal/controller/workloaddeployment_location_test.go
+++ b/internal/controller/workloaddeployment_location_test.go
@@ -62,14 +62,11 @@ func newTestLocation(name, cityCode string) *networkingv1alpha.Location {
}
}
-// TestReconcileNetworks_PersistsLocation_WhenLocationFound verifies that when a
+// TestResolveLocation_PersistsLocation_WhenLocationFound verifies that when a
// Location object matching the deployment's city code exists in the cluster, the
-// resolved LocationReference is returned by reconcileNetworks and can be persisted
-// to deployment.Status.Location. Instance creation must not be blocked — the
-// function returns networkReady=false only because no NetworkInterfaces exist on
-// the deployment in this scenario (short-circuit before bindings), not because
-// Location was absent.
-func TestReconcileNetworks_PersistsLocation_WhenLocationFound(t *testing.T) {
+// resolved LocationReference is returned and can be persisted to
+// deployment.Status.Location.
+func TestResolveLocation_PersistsLocation_WhenLocationFound(t *testing.T) {
t.Parallel()
const locationName = "loc-dfw-1"
@@ -83,13 +80,11 @@ func TestReconcileNetworks_PersistsLocation_WhenLocationFound(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{Name: "test-wd", Namespace: locTestWDNamespace},
Spec: computev1alpha.WorkloadDeploymentSpec{
CityCode: locTestCityCode,
- // No NetworkInterfaces — the function returns false,locationRef,nil
- // after the location is found but before bindings are checked.
},
}
r := &WorkloadDeploymentReconciler{}
- _, resolvedLocation, err := r.reconcileNetworks(context.Background(), cl, deployment)
+ resolvedLocation, err := r.resolveLocation(context.Background(), cl, deployment)
require.NoError(t, err)
require.NotNil(t, resolvedLocation,
@@ -103,12 +98,11 @@ func TestReconcileNetworks_PersistsLocation_WhenLocationFound(t *testing.T) {
"Status.Location.Name must match the resolved Location object name")
}
-// TestReconcileNetworks_ReturnsNilLocation_WhenNoLocationFound verifies that
-// when no Location object in the cluster matches the deployment's city code,
-// reconcileNetworks returns (false, nil, nil) — no error and no resolved
-// location. The caller must treat nil location as best-effort and must NOT block
-// instance creation.
-func TestReconcileNetworks_ReturnsNilLocation_WhenNoLocationFound(t *testing.T) {
+// TestResolveLocation_ReturnsNilLocation_WhenNoLocationFound verifies that when
+// no Location object in the cluster matches the deployment's city code, the
+// resolver returns (nil, nil) — no error and no resolved location. The caller
+// must treat a nil location as best-effort and must NOT block instance creation.
+func TestResolveLocation_ReturnsNilLocation_WhenNoLocationFound(t *testing.T) {
t.Parallel()
s := newNetworkingScheme()
@@ -124,10 +118,9 @@ func TestReconcileNetworks_ReturnsNilLocation_WhenNoLocationFound(t *testing.T)
}
r := &WorkloadDeploymentReconciler{}
- networkReady, resolvedLocation, err := r.reconcileNetworks(context.Background(), cl, deployment)
+ resolvedLocation, err := r.resolveLocation(context.Background(), cl, deployment)
require.NoError(t, err, "missing location must not cause an error")
- assert.False(t, networkReady, "network is not ready when no location is found")
assert.Nil(t, resolvedLocation,
"resolved location must be nil when no matching Location object exists")
diff --git a/internal/controller/workloaddeployment_setup_test.go b/internal/controller/workloaddeployment_setup_test.go
index 858e7990..29522bd2 100644
--- a/internal/controller/workloaddeployment_setup_test.go
+++ b/internal/controller/workloaddeployment_setup_test.go
@@ -27,8 +27,8 @@ import (
// the networking integration disabled the WorkloadDeployment reconciler starts
// cleanly on an edge cell that carries no networking.datumapis.com CRDs. The only
// registered cluster is the local cell; a build that left networking enabled would
-// register the NetworkBinding/Location/SubnetClaim/Subnet watches and crash during
-// cache sync with no matches for those kinds.
+// register the NetworkInterfaceClaim/Location watches and crash during cache sync
+// with no matches for those kinds.
func TestWorkloadDeploymentSetupWithManager_CellModeNoNetworkingCRD(t *testing.T) {
ctrl.SetLogger(zap.New(zap.UseDevMode(true), zap.WriteTo(os.Stderr)))
diff --git a/internal/features/features.go b/internal/features/features.go
index 8921f35a..e315fc89 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -22,15 +22,15 @@ import (
const (
// NetworkingIntegration controls whether the compute operator integrates with
- // the network-services-operator (VPC) for NetworkBinding provisioning and the
+ // the network-services-operator (VPC) for interface addressing and the
// Network scheduling gate on Instances.
//
// When disabled:
- // - No NetworkBinding objects are created.
+ // - No NetworkInterfaceClaim objects are created, and none are read.
// - The Network scheduling gate is not added to newly created Instances.
// - Any existing Network scheduling gate is actively removed.
// - The networking step is treated as immediately ready so Instances
- // proceed to the runtime without a NetworkBinding.
+ // proceed to the runtime without addresses of their own.
//
// This flag exists so operators can run compute on edge/lab cells where
// VPC/NSO is not yet functional. The default is disabled: cells carry no
diff --git a/internal/validation/instance_validation.go b/internal/validation/instance_validation.go
index 54cf98db..f7867684 100644
--- a/internal/validation/instance_validation.go
+++ b/internal/validation/instance_validation.go
@@ -170,9 +170,30 @@ func validateInstanceNetworkInterfaces(
allErrs = append(allErrs, field.Required(fieldPath, "must define at least one network interface"))
}
+ // An interface's name is what its claim, and therefore its addresses, is
+ // keyed by, so two interfaces of the same instance may not share one.
+ interfaceNames := sets.Set[string]{}
+
for i, networkInterface := range networkInterfaces {
indexPath := fieldPath.Index(i)
+ nameField := indexPath.Child("name")
+ // An empty name is defaulted by the API server, so only a value that was
+ // explicitly provided is checked here.
+ if len(networkInterface.Name) > 0 {
+ for _, msg := range apimachineryvalidation.NameIsDNSLabel(networkInterface.Name, false) {
+ allErrs = append(allErrs, field.Invalid(nameField, networkInterface.Name, msg))
+ }
+
+ if interfaceNames.Has(networkInterface.Name) {
+ allErrs = append(allErrs, field.Duplicate(nameField, networkInterface.Name))
+ } else {
+ interfaceNames.Insert(networkInterface.Name)
+ }
+ }
+
+ allErrs = append(allErrs, validateInstanceNetworkInterfaceAddresses(networkInterface.Addresses, indexPath.Child("addresses"))...)
+
networkField := indexPath.Child("network")
networkNameField := networkField.Child("name")
for _, msg := range apimachineryvalidation.NameIsDNSLabel(networkInterface.Network.Name, false) {
@@ -221,6 +242,40 @@ func validateInstanceNetworkInterfaces(
return allErrs
}
+// validateInstanceNetworkInterfaceAddresses validates the extra addresses an
+// interface asks for by IPAM class. A class names a kind of address, so the
+// same class twice on one interface is a request the platform cannot satisfy
+// distinctly.
+func validateInstanceNetworkInterfaceAddresses(
+ addresses []computev1alpha.InstanceNetworkInterfaceAddressRequest,
+ fieldPath *field.Path,
+) field.ErrorList {
+ allErrs := field.ErrorList{}
+
+ classes := sets.Set[string]{}
+
+ for i, address := range addresses {
+ classField := fieldPath.Index(i).Child("class")
+
+ if len(address.Class) == 0 {
+ allErrs = append(allErrs, field.Required(classField, ""))
+ continue
+ }
+
+ for _, msg := range apimachineryvalidation.NameIsDNSLabel(address.Class, false) {
+ allErrs = append(allErrs, field.Invalid(classField, address.Class, msg))
+ }
+
+ if classes.Has(address.Class) {
+ allErrs = append(allErrs, field.Duplicate(classField, address.Class))
+ } else {
+ classes.Insert(address.Class)
+ }
+ }
+
+ return allErrs
+}
+
func validateVolumes(spec computev1alpha.InstanceSpec, fieldPath *field.Path) (map[string]computev1alpha.VolumeSource, field.ErrorList) {
allErrs := field.ErrorList{}
allNames := sets.Set[string]{}