From e5e2f29061c862d20cdecc75ae222517dad4f6fd Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 17 Sep 2026 17:55:21 -0500 Subject: [PATCH 1/4] feat: Report interface egress on the instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance's network interface status already carries the addresses the interface holds, the addresses it is reachable at, and the conditions describing it. It said nothing about what the interface reaches outside the platform, so a consumer who has to allow-list the address their traffic arrives on at a destination had no way to read it from the instance. The egress addresses are published by networking on the NetworkInterface itself rather than repeated on the claim, so the status pass follows the claim's reference to the bound interface and copies what it reports. Reporting stays per interface: an instance with two interfaces egresses from each on its own addresses, and a consumer reads the entry for the interface its traffic leaves from rather than an instance-wide answer. Nothing is published until an address is reported, because a consumer is worse served by an empty block that reads as an answer than by no block at all. The instance declares no egress intent. A network declares it and an interface inherits it; this only reports what was realized. This branch is not mergeable until network-services-operator releases the NetworkInterface egress status it reads. v0.27.0 does not carry it, so building needs a local go.work pointing at an NSO checkout — left untracked, since a committed replace directive would break the single-repo CI checkout outright. Key changes: - Add status.networkInterfaces[].egress to Instance, reusing NSO's NetworkInterfaceEgressStatus so the copy needs no translation - Follow the claim's networkInterfaceRef to the bound NetworkInterface during the status pass, treating a missing interface as nothing to report - Publish no egress block unless the interface reports at least one source address - Cover the copy, the nothing-to-report cases, and an interface reporting more than one address Co-Authored-By: Claude Opus 5 (1M context) --- api/v1alpha/instance_types.go | 16 ++ api/v1alpha/zz_generated.deepcopy.go | 5 + .../compute.datumapis.com_instances.yaml | 68 +++++++++ docs/api/instances.md | 141 ++++++++++++++++++ internal/controller/instance_controller.go | 36 ++++- internal/controller/networkinterfaceclaim.go | 17 +++ .../networkinterfaceclaim_controller_test.go | 101 ++++++++++++- .../controller/networkinterfaceclaim_test.go | 87 ++++++++++- 8 files changed, 462 insertions(+), 9 deletions(-) diff --git a/api/v1alpha/instance_types.go b/api/v1alpha/instance_types.go index 789c5b00..9c25fc28 100644 --- a/api/v1alpha/instance_types.go +++ b/api/v1alpha/instance_types.go @@ -559,6 +559,22 @@ type InstanceNetworkInterfaceStatus struct { // +kubebuilder:validation:Optional ExternalAddresses []InstanceNetworkInterfaceExternalAddress `json:"externalAddresses,omitempty"` + // What this interface reaches outside the platform, including the source + // addresses its outbound traffic leaves on and how far each may be relied + // upon. + // + // Reported per interface rather than once for the instance. An instance with + // two interfaces egresses from each on its own addresses, so a consumer + // allow-listing an address at a destination reads the entry for the + // interface the traffic leaves from, not an instance-wide answer. + // + // Absent until something reports an address for this interface. An absent + // value says nothing has been reported yet; whether the interface is meant + // to reach anything at all is declared on its network. + // + // +kubebuilder:validation:Optional + Egress *networkingv1alpha.NetworkInterfaceEgressStatus `json:"egress,omitempty"` + // The observations of this interface's current state. Known condition types // are "Allocated" and "Programmed". // diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index 41406441..20e520a8 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -495,6 +495,11 @@ func (in *InstanceNetworkInterfaceStatus) DeepCopyInto(out *InstanceNetworkInter *out = make([]InstanceNetworkInterfaceExternalAddress, len(*in)) copy(*out, *in) } + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = new(apiv1alpha.NetworkInterfaceEgressStatus) + (*in).DeepCopyInto(*out) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]metav1.Condition, len(*in)) diff --git a/config/base/crd/bases/compute.datumapis.com_instances.yaml b/config/base/crd/bases/compute.datumapis.com_instances.yaml index 8d91620b..5f323427 100644 --- a/config/base/crd/bases/compute.datumapis.com_instances.yaml +++ b/config/base/crd/bases/compute.datumapis.com_instances.yaml @@ -1354,6 +1354,74 @@ spec: - type type: object type: array + egress: + description: |- + What this interface reaches outside the platform, including the source + addresses its outbound traffic leaves on and how far each may be relied + upon. + + Reported per interface rather than once for the instance. An instance with + two interfaces egresses from each on its own addresses, so a consumer + allow-listing an address at a destination reads the entry for the + interface the traffic leaves from, not an instance-wide answer. + + Absent until something reports an address for this interface. An absent + value says nothing has been reported yet; whether the interface is meant + to reach anything at all is declared on its network. + properties: + internet: + description: internet reports the internet egress realized + for this interface. + properties: + sourceAddresses: + description: |- + sourceAddresses are the addresses translation writes onto outbound + packets from this interface, with the reliance each one carries. + + A consumer whose destination needs an allow-list reads the answer here, + on the interface traffic leaves from, rather than on the network. The + network declares the intent; the interface is what carries it. + + An absent list means nothing has reported an address for this interface. + It does not mean the interface reaches nothing: whether the network + asked for egress is on the network, and whether the location could + provide it is the network context's InternetEgressReady condition. + items: + description: InternetEgressSourceAddress is one address + outbound traffic leaves on. + properties: + address: + description: |- + Address is the source address translation writes, without a prefix + length. + maxLength: 39 + minLength: 1 + type: string + family: + description: Family is the address family of this + source address. + enum: + - IPv4 + - IPv6 + type: string + stability: + description: |- + Stability states how far a consumer may rely on this address before + they act on it. It is the consumer-side projection of the serving + class's sharing. + enum: + - None + - Network + type: string + required: + - address + - family + - stability + type: object + maxItems: 16 + type: array + type: object + type: object externalAddresses: description: |- The addresses the interface is reachable at from outside its network, one diff --git a/docs/api/instances.md b/docs/api/instances.md index 05202277..2aba9181 100644 --- a/docs/api/instances.md +++ b/docs/api/instances.md @@ -2462,6 +2462,24 @@ one address per interface.
are "Allocated" and "Programmed".
false + + egress + object + + What this interface reaches outside the platform, including the source +addresses its outbound traffic leaves on and how far each may be relied +upon. + +Reported per interface rather than once for the instance. An instance with +two interfaces egresses from each on its own addresses, so a consumer +allow-listing an address at a destination reads the entry for the +interface the traffic leaves from, not an instance-wide answer. + +Absent until something reports an address for this interface. An absent +value says nothing has been reported yet; whether the interface is meant +to reach anything at all is declared on its network.
+ + false externalAddresses []object @@ -2677,6 +2695,129 @@ with respect to the current state of the instance.
+### Instance.status.networkInterfaces[index].egress +[↩ Parent](#instancestatusnetworkinterfacesindex) + + + +What this interface reaches outside the platform, including the source +addresses its outbound traffic leaves on and how far each may be relied +upon. + +Reported per interface rather than once for the instance. An instance with +two interfaces egresses from each on its own addresses, so a consumer +allow-listing an address at a destination reads the entry for the +interface the traffic leaves from, not an instance-wide answer. + +Absent until something reports an address for this interface. An absent +value says nothing has been reported yet; whether the interface is meant +to reach anything at all is declared on its network. + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
internetobject + internet reports the internet egress realized for this interface.
+
false
+ + +### Instance.status.networkInterfaces[index].egress.internet +[↩ Parent](#instancestatusnetworkinterfacesindexegress) + + + +internet reports the internet egress realized for this interface. + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sourceAddresses[]object + sourceAddresses are the addresses translation writes onto outbound +packets from this interface, with the reliance each one carries. + +A consumer whose destination needs an allow-list reads the answer here, +on the interface traffic leaves from, rather than on the network. The +network declares the intent; the interface is what carries it. + +An absent list means nothing has reported an address for this interface. +It does not mean the interface reaches nothing: whether the network +asked for egress is on the network, and whether the location could +provide it is the network context's InternetEgressReady condition.
+
false
+ + +### Instance.status.networkInterfaces[index].egress.internet.sourceAddresses[index] +[↩ Parent](#instancestatusnetworkinterfacesindexegressinternet) + + + +InternetEgressSourceAddress is one address outbound traffic leaves on. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
addressstring + Address is the source address translation writes, without a prefix +length.
+
true
familyenum + Family is the address family of this source address.
+
+ Enum: IPv4, IPv6
+
true
stabilityenum + Stability states how far a consumer may rely on this address before +they act on it. It is the consumer-side projection of the serving +class's sharing.
+
+ Enum: None, Network
+
true
+ + ### Instance.status.networkInterfaces[index].externalAddresses[index] [↩ Parent](#instancestatusnetworkinterfacesindex) diff --git a/internal/controller/instance_controller.go b/internal/controller/instance_controller.go index fd24a851..5b48b666 100644 --- a/internal/controller/instance_controller.go +++ b/internal/controller/instance_controller.go @@ -1952,11 +1952,16 @@ func (r *InstanceReconciler) reconcileNetworkInterfaceStatus( 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)) + interfaces = append(interfaces, instanceNetworkInterfaceStatus(interfaceName, nil, nil)) continue } - interfaces = append(interfaces, instanceNetworkInterfaceStatus(interfaceName, &claim)) + boundInterface, err := r.boundNetworkInterface(ctx, clusterClient, instance.Namespace, &claim) + if err != nil { + return false, err + } + + interfaces = append(interfaces, instanceNetworkInterfaceStatus(interfaceName, &claim, boundInterface)) } if apiequality.Semantic.DeepEqual(instance.Status.NetworkInterfaces, interfaces) { @@ -1967,6 +1972,33 @@ func (r *InstanceReconciler) reconcileNetworkInterfaceStatus( return true, nil } +// boundNetworkInterface reads the NetworkInterface a claim bound, for the +// fields the claim does not repeat. It returns nil while nothing is bound, and +// nil for an interface that has since been deleted, so a retained interface +// disappearing under a terminating instance does not wedge the status pass. +func (r *InstanceReconciler) boundNetworkInterface( + ctx context.Context, + clusterClient client.Client, + namespace string, + claim *networkingv1alpha.NetworkInterfaceClaim, +) (*networkingv1alpha.NetworkInterface, error) { + ref := claim.Status.NetworkInterfaceRef + if ref == nil || ref.Name == "" { + return nil, nil + } + + key := client.ObjectKey{Namespace: namespace, Name: ref.Name} + var networkInterface networkingv1alpha.NetworkInterface + if err := clusterClient.Get(ctx, key, &networkInterface); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed fetching network interface %s: %w", key, err) + } + + return &networkInterface, 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) { diff --git a/internal/controller/networkinterfaceclaim.go b/internal/controller/networkinterfaceclaim.go index c8d4d5c1..6f3aa18c 100644 --- a/internal/controller/networkinterfaceclaim.go +++ b/internal/controller/networkinterfaceclaim.go @@ -136,9 +136,14 @@ func networkInterfaceClaimRejection(claim *networkingv1alpha.NetworkInterfaceCla // instanceNetworkInterfaceStatus projects a claim's published addresses onto the // instance status entry for one interface. +// +// boundInterface is the NetworkInterface the claim bound, when it could be +// read. It carries the fields the claim does not repeat, and is nil while no +// interface is bound. func instanceNetworkInterfaceStatus( interfaceName string, claim *networkingv1alpha.NetworkInterfaceClaim, + boundInterface *networkingv1alpha.NetworkInterface, ) computev1alpha.InstanceNetworkInterfaceStatus { status := computev1alpha.InstanceNetworkInterfaceStatus{Name: interfaceName} if claim == nil { @@ -174,6 +179,18 @@ func instanceNetworkInterfaceStatus( status.Assignments.ExternalIP = new(status.ExternalAddresses[0].Address) } + // Egress is published on the interface rather than repeated on the claim, so + // it is read from the bound object. Only a reported address is copied: a + // consumer who allow-lists an egress address at a destination is worse served + // by an empty block that reads as an answer than by no block at all. + if boundInterface != nil { + if egress := boundInterface.Status.Egress; egress != nil && + egress.Internet != nil && + len(egress.Internet.SourceAddresses) > 0 { + status.Egress = egress.DeepCopy() + } + } + // 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. diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go index 4bee7e17..90e3f104 100644 --- a/internal/controller/networkinterfaceclaim_controller_test.go +++ b/internal/controller/networkinterfaceclaim_controller_test.go @@ -39,6 +39,15 @@ const ( // claimTestPendingReason is the reason NSO seeds a data-plane condition with. claimTestPendingReason = "Pending" + + // claimTestSecondInterface is the second interface on an instance, which + // carries addresses and egress of its own. + claimTestSecondInterface = "eth1" + + // The source addresses an interface's outbound traffic leaves on, which NSO + // publishes on the interface rather than on the claim. + claimTestEgressIPv4 = "198.51.100.7" + claimTestEgressIPv6 = "2001:db8:e000::7" ) // newClaimTestScheme builds a scheme carrying compute and networking types, the @@ -119,7 +128,7 @@ func TestReconcileNetworkInterfaceClaims_CreatesClaimPerInterface(t *testing.T) }, computev1alpha.InstanceNetworkInterface{ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork}, - Name: "eth1", + Name: claimTestSecondInterface, IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol}, ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyRetain, Addresses: []computev1alpha.InstanceNetworkInterfaceAddressRequest{ @@ -434,9 +443,93 @@ func TestInstancePublishesTheBoundNetworkInterface(t *testing.T) { // An unbound claim publishes no reference rather than an empty one. unbound := instanceNetworkInterfaceStatus(defaultInterfaceName, - &networkingv1alpha.NetworkInterfaceClaim{}) + &networkingv1alpha.NetworkInterfaceClaim{}, nil) assert.Nil(t, unbound.NetworkInterfaceRef) - assert.Nil(t, instanceNetworkInterfaceStatus(defaultInterfaceName, nil).NetworkInterfaceRef) + assert.Nil(t, instanceNetworkInterfaceStatus(defaultInterfaceName, nil, nil).NetworkInterfaceRef) +} + +// TestInstancePublishesInterfaceEgress covers the whole pull: the reconciler +// follows the claim's reference to the bound interface, reads the egress the +// interface reports, and publishes it on the entry for that interface. An +// instance with two interfaces reports each separately, so a consumer reads the +// answer for the interface its traffic leaves from. +func TestInstancePublishesInterfaceEgress(t *testing.T) { + t.Parallel() + + instance := newClaimTestInstance(claimTestDeployment+"-0", + computev1alpha.InstanceNetworkInterface{ + Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork}, + Name: defaultInterfaceName, + }, + computev1alpha.InstanceNetworkInterface{ + Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork}, + Name: claimTestSecondInterface, + }) + + egressInterface := &networkingv1alpha.NetworkInterface{ + ObjectMeta: metav1.ObjectMeta{Name: "nic-egress", Namespace: claimTestNamespace}, + Status: networkingv1alpha.NetworkInterfaceStatus{ + Egress: &networkingv1alpha.NetworkInterfaceEgressStatus{ + Internet: &networkingv1alpha.NetworkInterfaceInternetEgressStatus{ + SourceAddresses: []networkingv1alpha.InternetEgressSourceAddress{ + { + Family: networkingv1alpha.IPv4Protocol, + Address: claimTestEgressIPv4, + Stability: networkingv1alpha.InternetEgressAddressStabilityNetwork, + }, + { + Family: networkingv1alpha.IPv6Protocol, + Address: claimTestEgressIPv6, + Stability: networkingv1alpha.InternetEgressAddressStabilityNetwork, + }, + }, + }, + }, + }, + } + + silentInterface := &networkingv1alpha.NetworkInterface{ + ObjectMeta: metav1.ObjectMeta{Name: "nic-silent", Namespace: claimTestNamespace}, + } + + cl := fake.NewClientBuilder(). + WithScheme(newClaimTestScheme()). + WithObjects( + instance, + egressInterface, + silentInterface, + &networkingv1alpha.NetworkInterfaceClaim{ + ObjectMeta: metav1.ObjectMeta{Name: instance.Name + "-eth0", Namespace: claimTestNamespace}, + Status: networkingv1alpha.NetworkInterfaceClaimStatus{ + NetworkInterfaceRef: &networkingv1alpha.LocalNetworkInterfaceRef{Name: egressInterface.Name}, + }, + }, + &networkingv1alpha.NetworkInterfaceClaim{ + ObjectMeta: metav1.ObjectMeta{Name: instance.Name + "-eth1", Namespace: claimTestNamespace}, + Status: networkingv1alpha.NetworkInterfaceClaimStatus{ + NetworkInterfaceRef: &networkingv1alpha.LocalNetworkInterfaceRef{Name: silentInterface.Name}, + }, + }, + ). + 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, 2) + + eth0 := instance.Status.NetworkInterfaces[0] + require.NotNil(t, eth0.Egress) + require.NotNil(t, eth0.Egress.Internet) + require.Len(t, eth0.Egress.Internet.SourceAddresses, 2) + assert.Equal(t, claimTestEgressIPv4, eth0.Egress.Internet.SourceAddresses[0].Address) + assert.Equal(t, claimTestEgressIPv6, eth0.Egress.Internet.SourceAddresses[1].Address) + + eth1 := instance.Status.NetworkInterfaces[1] + assert.Nil(t, eth1.Egress, + "an interface reporting nothing publishes nothing, rather than borrowing the other interface's answer") } // TestNetworkGateReleasesWhileProgrammedIsNotTrue is the deadlock guard. The @@ -506,7 +599,7 @@ func TestNetworkGateReleasesWhileProgrammedIsNotTrue(t *testing.T) { } // The condition still reaches the consumer, just through status. - published := instanceNetworkInterfaceStatus(defaultInterfaceName, claim) + published := instanceNetworkInterfaceStatus(defaultInterfaceName, claim, nil) mirrored := false for _, condition := range published.Conditions { if condition.Type == networkingv1alpha.NetworkInterfaceClaimProgrammed { diff --git a/internal/controller/networkinterfaceclaim_test.go b/internal/controller/networkinterfaceclaim_test.go index 9e223998..0e97a728 100644 --- a/internal/controller/networkinterfaceclaim_test.go +++ b/internal/controller/networkinterfaceclaim_test.go @@ -97,7 +97,7 @@ func TestDesiredNetworkInterfaceClaimSpec(t *testing.T) { spec := desiredNetworkInterfaceClaimSpec(computev1alpha.InstanceNetworkInterface{ Network: networkingv1alpha.NetworkRef{Namespace: "other-namespace", Name: claimTestNetwork}, - Name: "eth1", + Name: claimTestSecondInterface, IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol, networkingv1alpha.IPv4Protocol}, ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyRetain, Addresses: []computev1alpha.InstanceNetworkInterfaceAddressRequest{ @@ -248,7 +248,7 @@ func TestInstanceNetworkInterfaceStatus(t *testing.T) { }, } - status := instanceNetworkInterfaceStatus(defaultInterfaceName, claim) + status := instanceNetworkInterfaceStatus(defaultInterfaceName, claim, nil) assert.Equal(t, defaultInterfaceName, status.Name) require.Len(t, status.Addresses, 2) @@ -281,13 +281,94 @@ func TestInstanceNetworkInterfaceStatus(t *testing.T) { func TestInstanceNetworkInterfaceStatus_NoClaim(t *testing.T) { t.Parallel() - status := instanceNetworkInterfaceStatus(defaultInterfaceName, nil) + status := instanceNetworkInterfaceStatus(defaultInterfaceName, nil, nil) assert.Equal(t, defaultInterfaceName, status.Name) assert.Empty(t, status.Addresses) assert.Nil(t, status.Assignments.NetworkIP) } +// TestInstanceNetworkInterfaceStatus_Egress verifies the egress addresses the +// bound interface reports are carried onto the instance entry unaltered, +// including every address when there is more than one, because a consumer +// allow-listing at a destination has to admit all of them. +func TestInstanceNetworkInterfaceStatus_Egress(t *testing.T) { + t.Parallel() + + boundInterface := &networkingv1alpha.NetworkInterface{ + Status: networkingv1alpha.NetworkInterfaceStatus{ + Egress: &networkingv1alpha.NetworkInterfaceEgressStatus{ + Internet: &networkingv1alpha.NetworkInterfaceInternetEgressStatus{ + SourceAddresses: []networkingv1alpha.InternetEgressSourceAddress{ + { + Family: networkingv1alpha.IPv4Protocol, + Address: claimTestEgressIPv4, + Stability: networkingv1alpha.InternetEgressAddressStabilityNetwork, + }, + { + Family: networkingv1alpha.IPv6Protocol, + Address: claimTestEgressIPv6, + Stability: networkingv1alpha.InternetEgressAddressStabilityNone, + }, + }, + }, + }, + }, + } + + status := instanceNetworkInterfaceStatus(defaultInterfaceName, + &networkingv1alpha.NetworkInterfaceClaim{}, boundInterface) + + require.NotNil(t, status.Egress) + require.NotNil(t, status.Egress.Internet) + require.Len(t, status.Egress.Internet.SourceAddresses, 2, + "every reported address reaches the consumer, not just the first") + assert.Equal(t, claimTestEgressIPv4, status.Egress.Internet.SourceAddresses[0].Address) + assert.Equal(t, networkingv1alpha.InternetEgressAddressStabilityNetwork, + status.Egress.Internet.SourceAddresses[0].Stability, + "stability decides whether the address may be allow-listed at all") + assert.Equal(t, claimTestEgressIPv6, status.Egress.Internet.SourceAddresses[1].Address) + assert.Equal(t, networkingv1alpha.InternetEgressAddressStabilityNone, + status.Egress.Internet.SourceAddresses[1].Stability) +} + +// TestInstanceNetworkInterfaceStatus_NoEgressReported verifies nothing is +// published when nothing is reported. A wrong egress address allow-lists the +// wrong sender at a destination, so an empty or placeholder block is worse than +// an absent one. +func TestInstanceNetworkInterfaceStatus_NoEgressReported(t *testing.T) { + t.Parallel() + + testCases := map[string]*networkingv1alpha.NetworkInterface{ + "no interface bound": nil, + "interface reports no egress at all": { + Status: networkingv1alpha.NetworkInterfaceStatus{}, + }, + "interface reports egress with no internet": { + Status: networkingv1alpha.NetworkInterfaceStatus{ + Egress: &networkingv1alpha.NetworkInterfaceEgressStatus{}, + }, + }, + "interface reports internet egress with no addresses": { + Status: networkingv1alpha.NetworkInterfaceStatus{ + Egress: &networkingv1alpha.NetworkInterfaceEgressStatus{ + Internet: &networkingv1alpha.NetworkInterfaceInternetEgressStatus{}, + }, + }, + }, + } + + for name, boundInterface := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + status := instanceNetworkInterfaceStatus(defaultInterfaceName, + &networkingv1alpha.NetworkInterfaceClaim{}, boundInterface) + assert.Nil(t, status.Egress) + }) + } +} + // 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 { From 4eb814b7b8dfdf43e0cbcc35a46544f9a4085e44 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 17 Sep 2026 20:56:19 -0500 Subject: [PATCH 2/4] chore: Pin the operator module to its egress branch CI checks out this repo alone, so a module replace pointing at a local path cannot resolve. Point it at the pushed commit instead, which CI can fetch, so the build reflects the branch this depends on. Key changes: - Replace the operator module with the pushed egress API commit - Drop the local workspace file in favour of a resolvable version Revert this replace once the operator module releases the field. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index a91eb1be..46b8c117 100644 --- a/go.mod +++ b/go.mod @@ -185,3 +185,5 @@ require ( go.datum.net/datumctl v0.19.0 mvdan.cc/sh/v3 v3.12.0 ) + +replace go.datum.net/network-services-operator => github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2 diff --git a/go.sum b/go.sum index 3b5e65e6..ca7142fc 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2 h1:2yKJV4XRmoQMNM+VrOJdUgNkP1pM5z6l2P/2qF+K5yI= +github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -337,8 +339,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= go.datum.net/datumctl v0.19.0 h1:e47nA0rfCVayfC4Yuta1Frf5t/c39JN/QOeQTgaMQyY= go.datum.net/datumctl v0.19.0/go.mod h1:KN/puWjphNMBxrHXxB2BfZHU/C09UppRZE+6grqcO0Y= -go.datum.net/network-services-operator v0.27.0 h1:LYCUjc6i0/f3c5YXSgisnbV8gB3R8emDwNfiYapdCAo= -go.datum.net/network-services-operator v0.27.0/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= go.miloapis.com/locations v0.0.1 h1:voJKqBzyLX5x96M3+5y/ga7fgq9/+vypTizd+bBvqUY= go.miloapis.com/locations v0.0.1/go.mod h1:gzfAfHhSMwl/N68k/uSNYXOKK3IOBJCdXYaBgCE3gdE= go.miloapis.com/milo v0.32.1 h1:xjtJ7bGJpF3KHkpJ2UXxyHmKDa7hBmTO8G8xjELNQXY= From 6d97b53e1129d257fb7dfb9e8569f0effbab3a6c Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 17 Sep 2026 21:03:13 -0500 Subject: [PATCH 3/4] fix: Resolve the operator module path through replaces The end-to-end environment installs the operator's CRDs and federation policy from a path built by hand out of the module's version. A replace directive changes where the module is downloaded without changing that version, so the constructed path does not exist and the environment fails to come up. Ask the toolchain for the module's directory instead, which is correct whether or not a replace is in effect. Key changes: - Read the operator module directory rather than composing it - Drop the version lookup the path no longer needs Co-Authored-By: Claude Opus 5 (1M context) --- Taskfile.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index fbb25262..01d8356a 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -566,8 +566,7 @@ tasks: # nothing reaches a cell. - | go mod download go.datum.net/network-services-operator - NSO_VERSION=$(go list -m -f '{{`{{.Version}}`}}' go.datum.net/network-services-operator) - NSO_CRD_PATH="$(go env GOMODCACHE)/go.datum.net/network-services-operator@${NSO_VERSION}/config/crd" + NSO_CRD_PATH="$(go list -m -f '{{`{{.Dir}}`}}' go.datum.net/network-services-operator)/config/crd" echo "NSO CRDs from: ${NSO_CRD_PATH}" for KC in \ {{.KUBECONFIG_DIR}}/control-plane.yaml \ @@ -595,8 +594,7 @@ tasks: # environment does not install (Gateway API, Envoy Gateway, cert-manager, # external-dns) simply never match. - | - NSO_VERSION=$(go list -m -f '{{`{{.Version}}`}}' go.datum.net/network-services-operator) - NSO_FEDERATION_PATH="$(go env GOMODCACHE)/go.datum.net/network-services-operator@${NSO_VERSION}/config/federation" + NSO_FEDERATION_PATH="$(go list -m -f '{{`{{.Dir}}`}}' go.datum.net/network-services-operator)/config/federation" echo "NSO federation policy from: ${NSO_FEDERATION_PATH}" kubectl --kubeconfig={{.KUBECONFIG_DIR}}/karmada.yaml apply \ -f "${NSO_FEDERATION_PATH}/clusterpropagationpolicy.yaml" \ From bc34c2c27557b5cf67708eea47481bd7314c3dc4 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 24 Sep 2026 18:10:21 -0500 Subject: [PATCH 4/4] chore: Build against the egress API without a class The interface's egress status is unchanged, so what this reads is the same. The pin moves to the head that removed the egress class so the module compiles against the API as it now stands. Co-Authored-By: Claude Fable 5.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 46b8c117..46bd081a 100644 --- a/go.mod +++ b/go.mod @@ -186,4 +186,4 @@ require ( mvdan.cc/sh/v3 v3.12.0 ) -replace go.datum.net/network-services-operator => github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2 +replace go.datum.net/network-services-operator => github.com/datum-cloud/network-services-operator v0.27.2-0.20260924225138-f78ee4cb7f7f diff --git a/go.sum b/go.sum index ca7142fc..d2cfb45b 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2 h1:2yKJV4XRmoQMNM+VrOJdUgNkP1pM5z6l2P/2qF+K5yI= -github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= +github.com/datum-cloud/network-services-operator v0.27.2-0.20260924225138-f78ee4cb7f7f h1:O/pK+GHjZqAQIBWYmkkEJNmxTPp6tmrL60bXMkWzyC0= +github.com/datum-cloud/network-services-operator v0.27.2-0.20260924225138-f78ee4cb7f7f/go.mod h1:IyFCDsNxfxvXHFfotZsD8eBBI9EyxpxAbQ7UTg6CNdw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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=