Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/rbac/kustomization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ resources:
- networkbinding_viewer_role.yaml
- networkcontext_editor_role.yaml
- networkcontext_viewer_role.yaml
- networkinterface_dataplane_role.yaml
- networkpolicy_editor_role.yaml
- networkpolicy_viewer_role.yaml
- subnet_editor_role.yaml
Expand Down
28 changes: 28 additions & 0 deletions config/rbac/networkinterface_dataplane_role.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Permissions for a data plane to report what it programmed. NSO seeds
# Programmed and never writes it again; this is the role for whoever does.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: network-services-operator
app.kubernetes.io/managed-by: kustomize
name: networkinterface-dataplane-role
rules:
- apiGroups:
- networking.datumapis.com
resources:
- networkinterfaces
- networkinterfaceclaims
verbs:
- get
- list
- watch
- apiGroups:
- networking.datumapis.com
resources:
- networkinterfaces/status
- networkinterfaceclaims/status
verbs:
- get
- patch
- update
30 changes: 22 additions & 8 deletions docs/enhancements/network-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,14 @@ the allocator to preserve.
A `NetworkInterface` says what an interface must be. `VPCAttachment`, in the
[cloud](https://github.com/datum-cloud/cloud) API group, is where it becomes real, and the
split is deliberate: an interface is allocated as soon as a claim exists, before an instance
has been scheduled to any node, while an attachment cannot exist until a node, a container,
and a veth pair do.
has been scheduled to any node, while what a node ends up carrying — a container ID, a host
device, a VRF — is only knowable once the sandbox exists.

The attachment is therefore **written as intent by the infrastructure provider and reported
on by the node**, not created by the node. It has to be, because the data plane reads it
before the sandbox exists: the CNI configuration a `VPCAttachment` produces must already be
in place when the pod's sandbox is created, so an attachment that only came into being as a
*result* of the attach would be too late to cause one.

```
NetworkInterfaceClaim compute's intent created per instance, per interface
Expand All @@ -501,17 +507,25 @@ NetworkInterfaceClaim compute's intent created per instance, per interfac
NetworkInterface NSO's answer addresses, gateway, MTU
│ realized by
VPCAttachment the node's reality node, containerID, VRF, veth, pod subnet
VPCAttachment spec: the provider intent, written before the pod
│ status: the node node, containerID, VRF, host device
│ attaches to
VPC the data plane base62 identity the fabric keys on
```

The agent on the node creates the `VPCAttachment` from the interface, copying
`spec.addresses` into `spec.interface.addresses` and naming the VPC backing this network in
this location. It reports back the facts only a node knows — the container ID, the host and
VRF device names, the pod subnet — and NSO sets `Programmed` on the interface when the
attachment reports ready, copying the VPC identifier onto `status.vpc`.
The infrastructure provider creates the `VPCAttachment` from the interface before it creates
the pod, copying `spec.addresses` into `spec.interface.addresses` and naming the VPC backing
this network in this location. The node reports back the facts only a node knows — the
container ID, the host and VRF device names, the pod subnet — and whoever owns the
attachment sets `Programmed` on the interface once the data plane carries it, recording the
VPC identifier on `status.vpc` and the attachment itself on `status.attachmentRef`.

**NSO does not write `Programmed`, `status.vpc` or `status.attachmentRef`, and does not
clear them.** It seeds `Programmed=Unknown` when the interface has none and leaves every
subsequent value alone, on every path — a requeue, a rejection, and a rebind after `Retain`
all preserve what the data plane reported. Whoever realizes the interface is the only writer
of those three fields, and NSO derives `Ready` from what it finds there.

Nothing in `VPCAttachment` changes to support this. It already requires the addresses to
have been decided elsewhere; this names the elsewhere.
Expand Down
94 changes: 94 additions & 0 deletions internal/controller/networkinterfaceclaim_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1873,3 +1873,97 @@ func TestAttachmentModeReachesTheInterface(t *testing.T) {
require.Equal(t, networkingv1alpha.NetworkInterfaceAttachmentModeNetns,
defaulted.Spec.AttachmentMode, "a claim that states no mode gets a namespace interface")
}

// The data plane owns Programmed, status.vpc and status.attachmentRef on the
// interface. Every NSO path that writes interface status has to leave all three
// where it found them, including a rebind after Retain.
func TestExternalAttachmentStatusSurvivesNSO(t *testing.T) {
s := newScenario(t, true, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol})

spec := networkingv1alpha.NetworkInterfaceClaimSpec{
InterfaceName: "eth0",
IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol},
ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyRetain,
}

s.reconcile(s.createClaim("slot-0-eth0", spec))

iface, err := s.getInterface("slot-0-eth0")
require.NoError(t, err)
require.Equal(t, metav1.ConditionUnknown,
apimeta.FindStatusCondition(iface.Status.Conditions,
networkingv1alpha.NetworkInterfaceProgrammed).Status,
"NSO seeds Programmed and waits for whoever realizes the interface")

// Stand in for the data plane reporting the attachment.
apimeta.SetStatusCondition(&iface.Status.Conditions, metav1.Condition{
Type: networkingv1alpha.NetworkInterfaceProgrammed,
Status: metav1.ConditionTrue,
Reason: "Programmed",
Message: "The attachment is ready",
})
iface.Status.VPC = "0000000ju"
iface.Status.AttachmentRef = &networkingv1alpha.NetworkInterfaceAttachmentRef{
APIGroup: "cloud.datumapis.com",
Kind: "VPCAttachment",
Name: "slot-0-eth0",
}
require.NoError(t, s.client.Status().Update(s.ctx, iface))

requireAttachmentReported := func(stage string) {
t.Helper()
reported, err := s.getInterface("slot-0-eth0")
require.NoError(t, err)
require.Equal(t, metav1.ConditionTrue,
apimeta.FindStatusCondition(reported.Status.Conditions,
networkingv1alpha.NetworkInterfaceProgrammed).Status,
"%s reverted the condition the data plane owns", stage)
require.Equal(t, "0000000ju", reported.Status.VPC, "%s cleared status.vpc", stage)
require.NotNil(t, reported.Status.AttachmentRef, "%s cleared status.attachmentRef", stage)
}

s.reconcile(s.getClaim("slot-0-eth0"))
requireAttachmentReported("a requeue")

s.reconcileInterface("slot-0-eth0")
requireAttachmentReported("an interface reconcile")

// Retain unbinds the interface and a replacement claim adopts it.
s.deleteClaim(s.getClaim("slot-0-eth0"))
requireAttachmentReported("release under Retain")

s.reconcile(s.createClaim("slot-0-eth0", spec))
requireAttachmentReported("a rebind")
}

// A rejection demotes the conditions NSO owns. Programmed is not one of them.
func TestRejectionLeavesProgrammedAlone(t *testing.T) {
s := newScenario(t, true, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol})

claim := s.createClaim("rejected-eth0", networkingv1alpha.NetworkInterfaceClaimSpec{
InterfaceName: "eth0",
IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol},
ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyDelete,
})
s.reconcile(claim)

bound := s.getClaim("rejected-eth0")
apimeta.SetStatusCondition(&bound.Status.Conditions, metav1.Condition{
Type: networkingv1alpha.NetworkInterfaceClaimProgrammed,
Status: metav1.ConditionTrue,
Reason: "Programmed",
Message: "The attachment is ready",
})
require.NoError(t, s.client.Status().Update(s.ctx, bound))

_, err := s.reconciler.reject(s.ctx, s.client, s.getClaim("rejected-eth0"),
"NetworkNotAvailableInLocation", "the network left this location")
require.NoError(t, err)

rejected := s.getClaim("rejected-eth0")
require.Equal(t, metav1.ConditionFalse,
conditionOf(rejected, networkingv1alpha.NetworkInterfaceClaimReady).Status)
require.Equal(t, metav1.ConditionTrue,
conditionOf(rejected, networkingv1alpha.NetworkInterfaceClaimProgrammed).Status,
"a rejection says nothing about what the data plane carries")
}
Loading