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. + + + + + + + + + + + + + + + + +
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/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 {