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" \
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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | internet |
+ object |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | address |
+ string |
+
+ Address is the source address translation writes, without a prefix
+length.
+ |
+ true |
+
+ | family |
+ enum |
+
+ Family is the address family of this source address.
+
+ Enum: IPv4, IPv6
+ |
+ true |
+
+ | stability |
+ enum |
+
+ 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/go.mod b/go.mod
index a91eb1be..46bd081a 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.20260924225138-f78ee4cb7f7f
diff --git a/go.sum b/go.sum
index 3b5e65e6..d2cfb45b 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.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=
@@ -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=
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 {