Skip to content
Draft
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
19 changes: 10 additions & 9 deletions docs/agents/ARCHITECTURE-GATEWAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,18 @@ per-VPC gateway design to colocate with the workload's own VRF.
| CRD | Scope | Written by | Purpose |
| ---------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NetworkGateway` | Namespaced, one per gateway node (`spec.targetRef.name` = node name) | Operator, once per gateway node (see [worked example](#worked-containerlab-example) below) | Node-scoped root object, mirroring `BGPRouter`'s pattern. Identifies which nodes participate in the anycast mesh and surfaces each node's engine health via `status.conditions`. Carries **no** self-address field — DSR rewrites nothing, so there is no translation source to publish. |
| `NetworkRule` | Namespaced, internal | The platform API that serves tenants, after it verifies the tenant owns `vpcRef` (galactic does not repeat the check — see Known Constraints) | Ingress load-balancing spec: `vpcRef` (opaque tenant identifier), `vipAddresses` (1–8, IPv4/IPv6), `protocol` (`tcp`/`udp`), `port`, `backendSelector` (the VPCAttachments in `vpcRef` that serve it), optional `backendAddresses` filter, `backendPort`. Served by every `NetworkGateway` in the namespace identically. |
| `NetworkRule` | Namespaced, internal | The platform API that serves tenants, after it verifies the tenant owns `vpcRef` (galactic does not repeat the check — see Known Constraints) | Ingress load-balancing spec: `vpcRef` (opaque tenant identifier), `vipAddresses` (1–8, IPv4/IPv6), `protocol` (`tcp`/`udp`), `port`, optional `backendSelector`, optional `backendAddresses`, `backendPort`. At least one backend selection field is required. Served by every `NetworkGateway` in the namespace identically. |

`backendAddresses` lets a control plane supply an explicit healthy IPv6
backend set while retaining the VPC-scoped attachment selector as an ownership
boundary. Omission keeps every selected address; an explicit empty list loads
no backends and withdraws the rule. Both the gateway and backend nodes filter
the same attachment addresses, so a removed address loses its ingress Maglev
slot and its `ServiceVIPBinding`. This does not change the DSR restriction:
one backend address, port and protocol can reply for only one IPv6 VIP. A
consumer must also map its service endpoint to the corresponding
`VPCAttachment` and its authoritative labels before writing a rule.
backend set. Without `backendSelector`, Galactic matches those addresses only
against attachments whose authoritative `status.vpc` equals `vpcRef`. With a
selector, it also applies the label filter. Omitting `backendAddresses` keeps
every selected address; an explicit empty list loads no backends and withdraws
the rule. Both gateway and backend nodes use the same attachment set, so a
removed address loses its ingress Maglev slot and its `ServiceVIPBinding`.
This does not change the DSR restriction: one backend address, port and
protocol can reply for only one IPv6 VIP. A consumer must verify its VPC
authorization before writing a rule.

Both are defined in `go.datum.net/network`'s `api/v1alpha1` package
(`gateway_types.go`, `rule_types.go`) — the same external CRD module the
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.2-0.20261002201547-1d1c62507e3c
go.datum.net/cloud v0.1.2
go.datum.net/network v0.4.1-0.20261012151636-f8e095afd3df
go.datum.net/network v0.4.1-0.20261012153112-c71c4527ba05
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ go.datum.net/network v0.4.0 h1:JeNcJD7Ln9QFnGdbr2sjSWOy4p96gHoUXr3S6tEaqJM=
go.datum.net/network v0.4.0/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU=
go.datum.net/network v0.4.1-0.20261012151636-f8e095afd3df h1:C4Tly3CH9ZUr4AhFJMH+SkLk9Omkg2uvmRh2i50/FCw=
go.datum.net/network v0.4.1-0.20261012151636-f8e095afd3df/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU=
go.datum.net/network v0.4.1-0.20261012153112-c71c4527ba05 h1:4JXe447a2gh+k6+2OupZm5gcf9JeBEGhs5XNy8g/QMY=
go.datum.net/network v0.4.1-0.20261012153112-c71c4527ba05/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/networkgateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ func buildDesiredRule(
return gateway.DesiredRule{}, nil, fmt.Errorf("no selected attachment is a backend yet: %s",
cappedList(unresolved))
case rule.Spec.BackendAddresses != nil && len(*rule.Spec.BackendAddresses) == 0:
return gateway.DesiredRule{}, nil, fmt.Errorf("backendAddresses is empty")
return gateway.DesiredRule{}, nil, errors.New("backendAddresses is empty")
case rule.Spec.BackendAddresses != nil:
return gateway.DesiredRule{}, nil, fmt.Errorf("backendAddresses match no selected VPCAttachment in VPC %s",
rule.Spec.VPCRef)
Expand Down
25 changes: 22 additions & 3 deletions internal/controller/networkgateway_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,8 +432,8 @@ func TestBuildDesiredRule_BackendResolution(t *testing.T) {
}{
{
name: "all resolve",
attachments: []*cloudv1alpha1.VPCAttachment{newBackendAttachment("vpc-1", testBackendAddr, "fd00:10::2")},
wantBackends: []string{testBackendAddr, "fd00:10::2"},
attachments: []*cloudv1alpha1.VPCAttachment{newBackendAttachment("vpc-1", testBackendAddr, testBackendAddr2)},
wantBackends: []string{testBackendAddr, testBackendAddr2},
},
{
name: "some unresolved",
Expand Down Expand Up @@ -587,7 +587,7 @@ func TestBuildDesiredRule_SameNodeBackendsGetTheirOwnSlot(t *testing.T) {
advs: []*bgpv1alpha1.BGPAdvertisement{backendAdv},
vrfInstances: map[string]*bgpv1alpha1.BGPVRFInstance{backendVRF.Name: backendVRF},
}
attachments := []*cloudv1alpha1.VPCAttachment{newBackendAttachment("vpc-1", testBackendAddr, "fd00:10::2")}
attachments := []*cloudv1alpha1.VPCAttachment{newBackendAttachment("vpc-1", testBackendAddr, testBackendAddr2)}

dr, _, err := buildDesiredRule(newTestRule(testRuleName, "vpc-1", testVIP), attachments, idx, nil)
if err != nil {
Expand Down Expand Up @@ -1005,6 +1005,25 @@ func TestNetworkGatewayReconciler_EmptyBackendAddressesWithdrawsRule(t *testing.
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rule), current); err != nil {
t.Fatalf("get rule: %v", err)
}
allowed := []string{testBackendAddr}
current.Spec.BackendSelector = metav1.LabelSelector{}
current.Spec.BackendAddresses = &allowed
if err := c.Update(context.Background(), current); err != nil {
t.Fatalf("update rule with address-only backends: %v", err)
}
if _, err := r.Reconcile(context.Background(), req); err != nil {
t.Fatalf("Reconcile with address-only backends: %v", err)
}
if len(engine.lastDesired.Rules) != 1 {
t.Fatalf("address-only desired rules = %+v, want the rule", engine.lastDesired.Rules)
}
if err := c.Get(context.Background(), testRuleKey(testRuleAdvV4), adv); err != nil {
t.Fatalf("get advertisement with address-only backends: %v", err)
}

if err := c.Get(context.Background(), client.ObjectKeyFromObject(rule), current); err != nil {
t.Fatalf("get rule before withdrawal: %v", err)
}
empty := []string{}
current.Spec.BackendAddresses = &empty
if err := c.Update(context.Background(), current); err != nil {
Expand Down
23 changes: 13 additions & 10 deletions internal/controller/networkrule_binding_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,33 +151,36 @@ func TestNetworkRuleBindingReconciler_WritesBindingForLocalBackend(t *testing.T)
// per-backend slot lets the node program side by side.
func TestNetworkRuleBindingReconciler_SameNodeBackendsGetOwnBindings(t *testing.T) {
rule := newBindingTestRule()
r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef, testBackendAddr, "fd00:10::2"))
r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef, testBackendAddr, testBackendAddr2))

reconcileBindings(t, r)

got := backendAddresses(listBindings(t, c))
if strings.Join(got, ",") != testBackendAddr+",fd00:10::2" {
t.Errorf("binding backends = %v, want both %s and fd00:10::2", got, testBackendAddr)
if strings.Join(got, ",") != testBackendAddr+","+testBackendAddr2 {
t.Errorf("binding backends = %v, want both %s and %s", got, testBackendAddr, testBackendAddr2)
}
}

func TestNetworkRuleBindingReconciler_RemovesFilteredBackend(t *testing.T) {
rule := newBindingTestRule()
r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef, testBackendAddr, "fd00:10::2"))
otherVPC := newBackendAttachment(testOtherVPC, testBackendAddr)
otherVPC.Name = testOtherVPC
r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef, testBackendAddr, testBackendAddr2), otherVPC)
reconcileBindings(t, r)
if got := backendAddresses(listBindings(t, c)); strings.Join(got, ",") != testBackendAddr+",fd00:10::2" {
if got := backendAddresses(listBindings(t, c)); strings.Join(got, ",") != testBackendAddr+","+testBackendAddr2 {
t.Fatalf("initial bindings = %v, want both backends", got)
}

current := getBindingRule(t, c)
allowed := []string{"fd00:10::2"}
allowed := []string{testBackendAddr2}
current.Spec.BackendSelector = metav1.LabelSelector{}
current.Spec.BackendAddresses = &allowed
if err := c.Update(context.Background(), current); err != nil {
t.Fatalf("update rule: %v", err)
}
reconcileBindings(t, r)
if got := backendAddresses(listBindings(t, c)); strings.Join(got, ",") != "fd00:10::2" {
t.Errorf("bindings after removing an address = %v, want only fd00:10::2", got)
if got := backendAddresses(listBindings(t, c)); strings.Join(got, ",") != testBackendAddr2 {
t.Errorf("bindings after removing an address = %v, want only %s", got, testBackendAddr2)
}

current = getBindingRule(t, c)
Expand Down Expand Up @@ -553,7 +556,7 @@ func TestNetworkRuleBindingReconciler_InvalidRuleRemovesStaleCondition(t *testin
// re-queues only the rules in its observed VPC.
func TestVPCRuleRequests(t *testing.T) {
inVPC := newBindingTestRule()
otherVPC := newTestRule("rule-other", "other-vpc", testBindingVIP)
otherVPC := newTestRule("rule-other", testOtherVPC, testBindingVIP)
_, c := newBindingWriter(t, inVPC, otherVPC)

tests := []struct {
Expand All @@ -562,7 +565,7 @@ func TestVPCRuleRequests(t *testing.T) {
want []string
}{
{"rule's VPC", testVPCRef, []string{testRuleName}},
{"other VPC", "other-vpc", []string{"rule-other"}},
{"other VPC", testOtherVPC, []string{"rule-other"}},
{"VPC with no rules", "no-rules", nil},
{"no observed VPC", "", nil},
}
Expand Down
2 changes: 2 additions & 0 deletions internal/controller/networkrule_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const (
// it to a real SRv6 uSID — required for buildDesiredRule to succeed
// (design plan decision #5; see usidresolver.go).
testBackendAddr = "fd00:10::1"
testBackendAddr2 = "fd00:10::2"
testOtherVPC = "other-vpc"
testBackendPrefix = "fd00:10::/64"
testBackendRouterName = "backend-router"
testBackendLocator = "2001:db8:ff01::/48"
Expand Down
14 changes: 9 additions & 5 deletions internal/controller/rulebackends.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ func selectRuleBackends(
return sel, nil
}

// ruleCandidateBackends returns every backend rule's BackendSelector picks
// from attachments, whatever other rules select, sorted by address, and the
// ruleCandidateBackends returns every backend selected by the rule from
// attachments, whatever other rules select, sorted by address, and the
// selected attachments that are not backends yet, as "namespace/name: reason".
// See selectRuleBackends for which attachments qualify.
func ruleCandidateBackends(
Expand Down Expand Up @@ -384,12 +384,16 @@ func ruleTranslatedVIP(rule *bgpv1alpha1.NetworkRule) (netip.Addr, bool, error)
}

// ruleBackendSelector converts rule's BackendSelector to a labels.Selector.
// The CRD rejects an empty selector; this rejects one anyway, since an empty
// selector would otherwise match every attachment in the VPC.
// An empty selector matches all attachments only when BackendAddresses is
// explicitly set; ruleCandidateBackends still requires each attachment's
// authoritative status.vpc to equal the rule's VPCRef before address matching.
func ruleBackendSelector(rule *bgpv1alpha1.NetworkRule) (labels.Selector, error) {
sel := rule.Spec.BackendSelector
if len(sel.MatchLabels) == 0 && len(sel.MatchExpressions) == 0 {
return nil, errors.New("backendSelector is empty")
if rule.Spec.BackendAddresses == nil {
return nil, errors.New("backendSelector is empty and backendAddresses is unset")
}
return labels.Everything(), nil
}
selector, err := metav1.LabelSelectorAsSelector(&sel)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/rulebackends_predicate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestVPCAttachmentBackendChanged(t *testing.T) {
a.Labels["extra"] = "x"
}, true},
{"status.vpc changed", func(a *cloudv1alpha1.VPCAttachment) {
a.Status.VPC = "other-vpc"
a.Status.VPC = testOtherVPC
}, true},
{"status.node changed", func(a *cloudv1alpha1.VPCAttachment) {
a.Status.Node = testOtherNode
Expand Down
23 changes: 16 additions & 7 deletions internal/controller/rulebackends_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ func TestSelectRuleBackends_DropsConflictingBackends(t *testing.T) {

func TestRuleCandidateBackends_BackendAddresses(t *testing.T) {
rule := newTestRule(testRuleName, testVPCRef, testVIP)
selected := newBackendAttachment(testVPCRef, "fd00:10::1", "fd00:10::2")
otherVPC := newBackendAttachment("other-vpc", "fd00:10::3")
selected := newBackendAttachment(testVPCRef, testBackendAddr, testBackendAddr2)
otherVPC := newBackendAttachment(testOtherVPC, testBackendAddr2, "fd00:10::3")
otherVPC.Name = testOtherVPC
attachments := []*cloudv1alpha1.VPCAttachment{selected, otherVPC}

check := func(want ...string) {
Expand All @@ -135,23 +136,31 @@ func TestRuleCandidateBackends_BackendAddresses(t *testing.T) {
}
got := make([]string, 0, len(backends))
for _, b := range backends {
if b.attachment.Name == otherVPC.Name {
t.Errorf("selected backend %s from another VPC", b)
}
got = append(got, b.addr.String())
}
if !slices.Equal(got, want) {
t.Errorf("backends = %v, want %v", got, want)
}
}

check("fd00:10::1", "fd00:10::2") // omitted: selector-only behavior
allowed := []string{"fd00:10::2", "fd00:10::3"}
check(testBackendAddr, testBackendAddr2) // omitted: selector-only behavior
allowed := []string{testBackendAddr2, "fd00:10::3"}
rule.Spec.BackendAddresses = &allowed
check("fd00:10::2") // no cross-VPC selection
rule.Spec.BackendSelector = metav1.LabelSelector{}
check(testBackendAddr2) // same address in another VPC still cannot be selected
allowed = []string{}
check() // explicit empty: no backends
check() // explicit empty with no selector: no backends
allowed = []string{"not-an-ip"}
if _, _, err := ruleCandidateBackends(rule, attachments); err == nil {
t.Error("invalid address: want an error")
}
rule.Spec.BackendAddresses = nil
if _, _, err := ruleCandidateBackends(rule, attachments); err == nil {
t.Error("empty selector without backendAddresses: want an error")
}
}

// newSharingRule returns a rule named name with the IPv6 VIP vip, created at
Expand Down Expand Up @@ -284,7 +293,7 @@ func TestRuleBackendOwners(t *testing.T) {
// backends and pending attachments the unfiltered list does.
func TestAttachmentsByVPC_SelectSameBackends(t *testing.T) {
inBlue := newBackendAttachment("vpc-blue", "fd00:10::1")
alsoBlue := newBackendAttachment("vpc-blue", "fd00:10::2")
alsoBlue := newBackendAttachment("vpc-blue", testBackendAddr2)
alsoBlue.Name = "also-blue"
alsoBlue.Status.Node = ""
inGreen := newBackendAttachment("vpc-green", "fd00:20::1")
Expand Down
Loading