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
4 changes: 4 additions & 0 deletions api/v1alpha/networkinterface_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,8 @@ const (
// published from, so a collector can find the source of a copy it is looking
// at.
NetworkInterfaceSourceNamespaceLabel = "networking.datumapis.com/source-namespace"

// NetworkInterfaceSourceUIDLabel identifies the exact cell interface behind
// a published copy. A replacement may reuse the name, but never the UID.
NetworkInterfaceSourceUIDLabel = "networking.datumapis.com/source-uid"
)
95 changes: 95 additions & 0 deletions docs/plans/network-load-balancer-cell-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Network load balancer cell contract

## Identity available today

A project's `NetworkInterface` is a copy of a cell interface. The copy has the
cell location, source namespace, and source name. This change also publishes the
source interface UID. These four values identify one source object, including
across deletion and recreation under the same name. The copy deliberately omits
`status.attachmentRef` and `status.vpc`; those values name cell resources and
are not authoritative in a project control plane.

The route resolver counts only projected endpoints carrying this source UID.
Older copies without the label remain unprogrammable until the cell publishes
them again. The UID on a copy is a hint for the cell to check against its live
object, never authorization by itself.

Inside a cell, Cloud creates a `VPCAttachment` with
`spec.interfaceRef.name` pointing to the source interface in the same
namespace. The source interface reports the attachment in
`status.attachmentRef` and the fabric VPC identifier in `status.vpc`. Galactic
reads attachment addresses, `status.vpc`, and `status.node`. Matching an address
alone is unsafe because different VPCs can use the same address.

## Proposed intent

The project route reconciler should publish a **versioned, location-scoped
intent** through the hub to the target cell. This resource does not exist yet.
The owner reference must identify the route by UID and generation so a deleted
or replaced route cannot keep a VIP active. Its minimum schema is:

```yaml
apiVersion: networking.datumapis.com/v1alpha
kind: NetworkLoadBalancerCellIntent
spec:
source:
projectCluster: <project cluster identity>
namespace: <project namespace>
routeName: <route name>
routeUID: <route UID>
routeGeneration: <route generation>
parentName: <load balancer name>
listenerName: <listener name>
location: <cell location>
networkName: <project network name>
vip: <allocated IPv6 address>
protocol: TCP # or UDP
listenerPort: 443
backends:
- sourceNamespace: <cell namespace>
sourceName: <cell NetworkInterface name>
sourceUID: <cell NetworkInterface UID>
address: <interface IPv6 address>
port: 8443
weight: 1
healthCheck: <versioned probe specification>
```

Only a platform controller may write this intent. The cell must treat the
projected source tuple as a lookup hint, not as proof. For every backend it
must read the cell `NetworkInterface` and require the UID, network, bound phase,
holder availability, location, and address to match. It must then read the
same-namespace `VPCAttachment` named by the interface's `status.attachmentRef`
and require its `spec.interfaceRef.name`, controller owner UID, `status.vpc`,
IPv6 address, and programmed generation to match the source. The attachment
must have a node and the source must identify the expected VPC. A missing or
changed field removes the backend from the effective set. The cell must also
verify that the source namespace belongs to the project recorded in the
intent, and that the attachment's `spec.vpc` resolves to a live VPC whose
fabric ID matches both statuses. A source UID change invalidates old intent
even when the name and IP are reused.

The cell should report resolved attachment identities and probe results per
backend, with the intent generation it observed. A project controller may set
route `Programmed=True` only after the cell has acknowledged that generation
and every announcing gateway node confirms the same healthy backend set. VIP
announcement follows that acknowledgment; an empty healthy set withdraws the
VIP but retains the allocated address.

## Galactic API dependencies

The current `NetworkRule` requires a nonempty `backendSelector`. Cloud's
`VPCAttachment` writer does not provide a trusted label that selects all and
only this route's attachments. `backendAddresses` narrows a selector, so it
cannot be used alone today. The Network API should permit an omitted selector
**only when `backendAddresses` is explicitly set**. Galactic should then
select attachments by authoritative `status.vpc == spec.vpcRef` and intersect
their IPv6 addresses with `backendAddresses`. An explicit empty list means no
backends and withdrawal. The writer must verify authorization for `vpcRef`;
the existing Galactic admission contract does not itself prove it.

Current DSR also cannot safely serve two VIPs sharing a backend
address/port/protocol, and does not implement weighted selection or active
application probes. Those capabilities and an all-gateway programming
acknowledgment are required before this intent can drive the full native API.
Until then, route and load balancer `Programmed` and `Announced` remain false.
3 changes: 3 additions & 0 deletions internal/controller/networkinterface_projection.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func projectionLabels(source *networkingv1alpha.NetworkInterface, location strin

labels[networkingv1alpha.NetworkInterfaceProjectionLabel] = "true"
labels[networkingv1alpha.NetworkInterfaceSourceNamespaceLabel] = source.Namespace
if source.UID != "" {
labels[networkingv1alpha.NetworkInterfaceSourceUIDLabel] = string(source.UID)
}

if location != "" {
labels[networkingv1alpha.NetworkInterfaceLocationLabel] = location
Expand Down
20 changes: 19 additions & 1 deletion internal/controller/networkinterface_projection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func (v *visibility) projectCopy() (*networkingv1alpha.NetworkInterface, bool) {

func TestInterfaceReachesTheProjectControlPlane(t *testing.T) {
v := newVisibility(t)
v.interfaceOnCell()
source := v.interfaceOnCell()

v.publish()
v.handToProject()
Expand All @@ -365,6 +365,24 @@ func TestInterfaceReachesTheProjectControlPlane(t *testing.T) {

require.Equal(t, testLocationName, copied.Labels[networkingv1alpha.NetworkInterfaceLocationLabel])
require.Equal(t, boundInterfaceName, copied.Labels[networkingv1alpha.NetworkInterfaceHolderLabel])
require.Equal(t, v.cellNamespace, copied.Labels[networkingv1alpha.NetworkInterfaceSourceNamespaceLabel])
require.Equal(t, string(source.UID), copied.Labels[networkingv1alpha.NetworkInterfaceSourceUIDLabel])
}

func TestProjectedInterfaceSourceIdentityChangesWithUID(t *testing.T) {
source := &networkingv1alpha.NetworkInterface{ObjectMeta: metav1.ObjectMeta{
Namespace: "cell", Name: "nic", UID: "first",
Labels: map[string]string{networkingv1alpha.NetworkInterfaceSourceUIDLabel: "forged"},
}}
first := projectedInterface(source, "dfw")
require.Equal(t, "first", first.Labels[networkingv1alpha.NetworkInterfaceSourceUIDLabel])
source.UID = "second"
second := projectedInterface(source, "dfw")
require.Equal(t, "second", second.Labels[networkingv1alpha.NetworkInterfaceSourceUIDLabel])
require.Equal(t, "cell", second.Labels[networkingv1alpha.NetworkInterfaceSourceNamespaceLabel])
source.UID = ""
third := projectedInterface(source, "dfw")
require.NotContains(t, third.Labels, networkingv1alpha.NetworkInterfaceSourceUIDLabel)
}

func TestProjectedInterfaceReportsDNSSettings(t *testing.T) {
Expand Down
31 changes: 19 additions & 12 deletions internal/controller/networkloadbalancerroute_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ type NetworkLoadBalancerRouteReconciler struct {
}

type networkLoadBalancerRouteEndpoint struct {
NetworkService string
Interface string
Location string
Address string
Port int32
NetworkService string
Interface string
SourceNamespace string
SourceUID string
Location string
Address string
Port int32
}

type networkLoadBalancerRouteReferenceError struct {
Expand Down Expand Up @@ -220,7 +222,7 @@ func reconcileRouteParent(
fmt.Sprintf("All backend references resolved; %d holder-available IPv6 endpoints are eligible", len(endpoints)))
if status.Effective && len(endpoints) == 0 {
setRouteParentCondition(&status, route.Generation, networkingv1alpha.NetworkLoadBalancerRouteProgrammed,
metav1.ConditionFalse, nlbRouteReasonNoEligibleEndpoints, "No enabled backend has a holder-available IPv6 endpoint in a selected location")
metav1.ConditionFalse, nlbRouteReasonNoEligibleEndpoints, "No enabled backend has a holder-available IPv6 endpoint with source identity in a selected location")
}
return status, nil
}
Expand Down Expand Up @@ -493,19 +495,24 @@ func resolveNetworkLoadBalancerRouteBackends(
for i := range members {
member := &members[i]
location := member.Labels[networkingv1alpha.NetworkInterfaceLocationLabel]
if location == "" || !routeLocationSelected(parent, listener, location) || !isServiceMemberHealthy(member) {
sourceNamespace := member.Labels[networkingv1alpha.NetworkInterfaceSourceNamespaceLabel]
sourceUID := member.Labels[networkingv1alpha.NetworkInterfaceSourceUIDLabel]
if !isProjection(member) || sourceNamespace == "" || sourceUID == "" ||
location == "" || !routeLocationSelected(parent, listener, location) || !isServiceMemberHealthy(member) {
continue
}
address, ok := addressOfType(interfaceBackhaulAddresses(member), discoveryv1.AddressTypeIPv6)
if !ok {
continue
}
endpoints = append(endpoints, networkLoadBalancerRouteEndpoint{
NetworkService: ref.Name,
Interface: member.Name,
Location: location,
Address: address,
Port: port.Port,
NetworkService: ref.Name,
Interface: member.Name,
SourceNamespace: sourceNamespace,
SourceUID: sourceUID,
Location: location,
Address: address,
Port: port.Port,
})
}
}
Expand Down
17 changes: 16 additions & 1 deletion internal/controller/networkloadbalancerroute_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ func testRouteMember(name string, available bool, address string) *networkingv1a
return &networkingv1alpha.NetworkInterface{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test", Name: name,
Labels: map[string]string{"app": "storefront", networkingv1alpha.NetworkInterfaceLocationLabel: "dfw"},
Labels: map[string]string{
"app": "storefront",
networkingv1alpha.NetworkInterfaceLocationLabel: "dfw",
networkingv1alpha.NetworkInterfaceProjectionLabel: "true",
networkingv1alpha.NetworkInterfaceSourceNamespaceLabel: "cell-test",
networkingv1alpha.NetworkInterfaceSourceUIDLabel: "source-" + name,
},
},
Spec: networkingv1alpha.NetworkInterfaceSpec{
Network: networkingv1alpha.LocalNetworkRef{Name: "default"},
Expand Down Expand Up @@ -508,6 +514,8 @@ func TestNetworkLoadBalancerRouteEndpointResolution(t *testing.T) {
require.NoError(t, err)
require.Len(t, endpoints, 1)
assert.Equal(t, "fd00::1", endpoints[0].Address)
assert.Equal(t, "cell-test", endpoints[0].SourceNamespace)
assert.Equal(t, "source-ready-v6", endpoints[0].SourceUID)
assert.Equal(t, "dfw", endpoints[0].Location)

parent.Spec.Exposure.Addresses[0].Locations = &networkingv1alpha.NetworkLoadBalancerLocationSelection{
Expand All @@ -521,6 +529,13 @@ func TestNetworkLoadBalancerRouteEndpointResolution(t *testing.T) {
endpoints, err = resolveNetworkLoadBalancerRouteBackends(ctx, cl, route, parent, &parent.Spec.Listeners[0])
require.NoError(t, err)
require.Len(t, endpoints, 1, "omitted locations must follow backend membership")

member := testRouteMember("missing-source-uid", true, "fd00::3/128")
delete(member.Labels, networkingv1alpha.NetworkInterfaceSourceUIDLabel)
cl = fake.NewClientBuilder().WithScheme(networkServiceTestScheme(t)).WithObjects(service, member).Build()
endpoints, err = resolveNetworkLoadBalancerRouteBackends(ctx, cl, route, parent, &parent.Spec.Listeners[0])
require.NoError(t, err)
assert.Empty(t, endpoints)
}

func TestNetworkLoadBalancerRouteUDPRequiresTCPProbe(t *testing.T) {
Expand Down
Loading