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
57 changes: 57 additions & 0 deletions internal/controller/networkgateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import (
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/util/retry"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"

"go.datum.net/galactic/internal/gateway"
Expand Down Expand Up @@ -962,25 +965,79 @@ func (r *NetworkGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error {
// reconcile and the rule would stay broken until an unrelated event.
// Each broadcasts to every NetworkGateway in the namespace, since any
// of them could be the one waiting on this object.
//
// Backend resolution reads only these objects' specs, so status-only
// updates are filtered out, and advertisements that can never locate a
// backend (the gateway's own VIP advertisements among them) are
// filtered out entirely. Every pod attach still writes one that passes;
// the engine then skips every rule whose resolved state is unchanged.
Watches(&bgpv1alpha1.BGPRouter{}, handler.EnqueueRequestsFromMapFunc(
func(ctx context.Context, obj client.Object) []ctrlreconcile.Request {
return broadcastToGatewayRequests(ctx, r.Client, obj.GetNamespace(), "BGPRouter", obj.GetName())
}),
builder.WithPredicates(predicate.GenerationChangedPredicate{}),
).
Watches(&bgpv1alpha1.BGPAdvertisement{}, handler.EnqueueRequestsFromMapFunc(
func(ctx context.Context, obj client.Object) []ctrlreconcile.Request {
return broadcastToGatewayRequests(ctx, r.Client, obj.GetNamespace(), "BGPAdvertisement", obj.GetName())
}),
builder.WithPredicates(backendAdvertisementPredicate()),
).
Watches(&bgpv1alpha1.BGPVRFInstance{}, handler.EnqueueRequestsFromMapFunc(
func(ctx context.Context, obj client.Object) []ctrlreconcile.Request {
return broadcastToGatewayRequests(ctx, r.Client, obj.GetNamespace(), "BGPVRFInstance", obj.GetName())
}),
builder.WithPredicates(predicate.GenerationChangedPredicate{}),
).
Named("networkgateway").
Complete(r)
}

// backendAdvertisementPredicate passes only BGPAdvertisement events a pass can
// act on.
//
// buildBackendSIDIndex ignores an advertisement without both VRFID and
// Function, so a create or delete of one is dropped. An update passes when its
// spec changed and either side carries both, so an advertisement gaining or
// losing them still triggers a pass.
//
// The gateway's own VIP advertisements carry networkRuleLabel and neither
// field. Their creates come from this reconciler and are dropped, but a delete
// or a spec edit passes, so the next pass restores what was lost.
func backendAdvertisementPredicate() predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return locatesBackend(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool {
return locatesBackend(e.Object) || isRuleAdvertisement(e.Object)
},
UpdateFunc: func(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
if e.ObjectOld.GetGeneration() == e.ObjectNew.GetGeneration() {
return false
}
return locatesBackend(e.ObjectOld) || locatesBackend(e.ObjectNew) ||
isRuleAdvertisement(e.ObjectOld) || isRuleAdvertisement(e.ObjectNew)
},
GenericFunc: func(e event.GenericEvent) bool { return locatesBackend(e.Object) },
}
}

// locatesBackend reports whether obj is a BGPAdvertisement buildBackendSIDIndex
// would consider: one carrying both a VRFID and a Function.
func locatesBackend(obj client.Object) bool {
adv, ok := obj.(*bgpv1alpha1.BGPAdvertisement)
return ok && adv.Spec.VRFID != nil && adv.Spec.Function != nil
}

// isRuleAdvertisement reports whether obj is a gateway VIP advertisement made
// for a NetworkRule.
func isRuleAdvertisement(obj client.Object) bool {
_, ok := obj.GetLabels()[networkRuleLabel]
return ok
}

// ruleToGatewayRequests maps a NetworkRule change to every NetworkGateway in
// its namespace. A NetworkRule carries no gatewayRef, and every gateway node
// advertises every VIP identically (anycast), so every gateway node in the
Expand Down
88 changes: 88 additions & 0 deletions internal/controller/networkgateway_predicate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package controller

import (
"testing"

"sigs.k8s.io/controller-runtime/pkg/event"

bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
)

func testAdvertisement(generation int64, srv6 bool) *bgpv1alpha1.BGPAdvertisement {
adv := &bgpv1alpha1.BGPAdvertisement{}
adv.Name = "adv"
adv.Namespace = "ns"
adv.Generation = generation
if srv6 {
adv.Spec.VRFID = ptr(int32(7))
adv.Spec.Function = ptr(bgpv1alpha1.SRv6FunctionEndDT6)
}
return adv
}

func TestBackendAdvertisementPredicate(t *testing.T) {
p := backendAdvertisementPredicate()

t.Run("create or delete without VRFID and Function is dropped", func(t *testing.T) {
adv := testAdvertisement(1, false)
if p.Create(event.CreateEvent{Object: adv}) {
t.Error("Create passed an advertisement that cannot locate a backend")
}
if p.Delete(event.DeleteEvent{Object: adv}) {
t.Error("Delete passed an advertisement that cannot locate a backend")
}
})

t.Run("create or delete with VRFID and Function passes", func(t *testing.T) {
adv := testAdvertisement(1, true)
if !p.Create(event.CreateEvent{Object: adv}) {
t.Error("Create dropped a backend advertisement")
}
if !p.Delete(event.DeleteEvent{Object: adv}) {
t.Error("Delete dropped a backend advertisement")
}
})

t.Run("gateway VIP advertisement heals on delete or edit but not create", func(t *testing.T) {
vipAdv := func(generation int64) *bgpv1alpha1.BGPAdvertisement {
adv := testAdvertisement(generation, false)
adv.Labels = map[string]string{networkRuleLabel: "rule"}
return adv
}
if p.Create(event.CreateEvent{Object: vipAdv(1)}) {
t.Error("Create passed the gateway's own VIP advertisement")
}
if !p.Delete(event.DeleteEvent{Object: vipAdv(1)}) {
t.Error("Delete dropped a gateway VIP advertisement")
}
if !p.Update(event.UpdateEvent{ObjectOld: vipAdv(1), ObjectNew: vipAdv(2)}) {
t.Error("Update dropped a spec edit to a gateway VIP advertisement")
}
if p.Update(event.UpdateEvent{ObjectOld: vipAdv(1), ObjectNew: vipAdv(1)}) {
t.Error("Update passed a status-only change to a gateway VIP advertisement")
}
})

tests := []struct {
name string
old, new *bgpv1alpha1.BGPAdvertisement
want bool
}{
{"status-only update", testAdvertisement(1, true), testAdvertisement(1, true), false},
{"spec change on a backend advertisement", testAdvertisement(1, true), testAdvertisement(2, true), true},
{"spec change on a VIP advertisement", testAdvertisement(1, false), testAdvertisement(2, false), false},
{"VRFID and Function removed", testAdvertisement(1, true), testAdvertisement(2, false), true},
{"VRFID and Function added", testAdvertisement(1, false), testAdvertisement(2, true), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := p.Update(event.UpdateEvent{ObjectOld: tt.old, ObjectNew: tt.new}); got != tt.want {
t.Errorf("Update = %v, want %v", got, tt.want)
}
})
}
}
30 changes: 25 additions & 5 deletions internal/gateway/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,26 @@

package gateway

import "sort"
import (
"slices"
"sort"
)

// diffRuleKeys compares the currently active rule keys against the desired state
// and returns which need applying and which removing.
//
// A key present in both is always returned in toApply: the engine applies
// unconditionally rather than diffing individual field changes. The two slices
// partition the key space, not changed against unchanged.
// A key is in toApply when it is not active yet or its desired rule differs from
// the active one. An unchanged rule is left out, so an event that changes nothing
// a rule depends on, such as a pod attach elsewhere in the namespace, writes
// nothing to the datapath.
//
// Both are sorted, for deterministic log output and test assertions, the inputs
// being maps whose iteration order is randomized.
func diffRuleKeys(active map[string]DesiredRule, desired map[string]DesiredRule) (toApply, toRemove []string) {
for key := range desired {
for key, rule := range desired {
if cur, ok := active[key]; ok && rulesEqual(cur, rule) {
continue
}
toApply = append(toApply, key)
}
for key := range active {
Expand All @@ -28,3 +35,16 @@ func diffRuleKeys(active map[string]DesiredRule, desired map[string]DesiredRule)
sort.Strings(toRemove)
return toApply, toRemove
}

// rulesEqual reports whether a and b would program identical datapath state.
// VIPAddresses and Backends compare in order: the controller builds both in
// spec order, so a reordered spec reapplies once with the same result.
func rulesEqual(a, b DesiredRule) bool {
return a.Key == b.Key &&
a.VPCRef == b.VPCRef &&
a.VPCAttachmentRef == b.VPCAttachmentRef &&
a.Protocol == b.Protocol &&
a.Port == b.Port &&
slices.Equal(a.VIPAddresses, b.VIPAddresses) &&
slices.Equal(a.Backends, b.Backends)
}
62 changes: 54 additions & 8 deletions internal/gateway/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,33 @@
package gateway

import (
"net/netip"
"reflect"
"testing"
)

const (
testKeyA = "ns/a"
testKeyB = "ns/b"
testKeyKeep = "ns/keep"
testKeyA = "ns/a"
testKeyB = "ns/b"
testKeyKeep = "ns/keep"
testKeyChanged = "ns/changed"
)

func TestDiffRuleKeys_ApplyAndRemove(t *testing.T) {
active := map[string]DesiredRule{
testKeyKeep: {Key: testKeyKeep},
testKeyA: {Key: testKeyA},
testKeyKeep: {Key: testKeyKeep, Port: 80},
testKeyChanged: {Key: testKeyChanged, Port: 80},
testKeyA: {Key: testKeyA},
}
desired := map[string]DesiredRule{
testKeyKeep: {Key: testKeyKeep},
testKeyB: {Key: testKeyB},
testKeyKeep: {Key: testKeyKeep, Port: 80},
testKeyChanged: {Key: testKeyChanged, Port: 443},
testKeyB: {Key: testKeyB},
}

toApply, toRemove := diffRuleKeys(active, desired)

if want := []string{testKeyB, testKeyKeep}; !reflect.DeepEqual(toApply, want) {
if want := []string{testKeyB, testKeyChanged}; !reflect.DeepEqual(toApply, want) {
t.Errorf("toApply = %v, want %v", toApply, want)
}
if want := []string{testKeyA}; !reflect.DeepEqual(toRemove, want) {
Expand Down Expand Up @@ -63,3 +67,45 @@ func TestDiffRuleKeys_AllRemovedNoDesired(t *testing.T) {
t.Errorf("toRemove = %v, want %v", toRemove, want)
}
}

func TestRulesEqual(t *testing.T) {
vip := netip.MustParseAddr("2001:db8::1")
usidA := netip.MustParseAddr("fc00:0:1::")
usidB := netip.MustParseAddr("fc00:0:2::")
backend := func(usid netip.Addr) DesiredBackend {
return DesiredBackend{Address: netip.MustParseAddr("2001:db8:1::10"), Port: 8080, USID: usid}
}
base := DesiredRule{
Key: testKeyA, VPCRef: "vpc", VPCAttachmentRef: "att",
VIPAddresses: []netip.Addr{vip}, Protocol: "udp", Port: 80,
Backends: []DesiredBackend{backend(usidA)},
}
clone := func(mut func(*DesiredRule)) DesiredRule {
r := base
r.VIPAddresses = append([]netip.Addr(nil), base.VIPAddresses...)
r.Backends = append([]DesiredBackend(nil), base.Backends...)
mut(&r)
return r
}

tests := []struct {
name string
b DesiredRule
want bool
}{
{"identical copy", clone(func(*DesiredRule) {}), true},
{"backend uSID moved", clone(func(r *DesiredRule) { r.Backends[0] = backend(usidB) }), false},
{"backend added", clone(func(r *DesiredRule) { r.Backends = append(r.Backends, backend(usidB)) }), false},
{"VIP removed", clone(func(r *DesiredRule) { r.VIPAddresses = nil }), false},
{"port changed", clone(func(r *DesiredRule) { r.Port = 443 }), false},
{"protocol changed", clone(func(r *DesiredRule) { r.Protocol = "" }), false},
{"VPC changed", clone(func(r *DesiredRule) { r.VPCRef = "other" }), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := rulesEqual(base, tt.b); got != tt.want {
t.Errorf("rulesEqual = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading