Skip to content
Open
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 designs/site-node-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SiteNodeSlice Reconciliation

## Purpose

The site controller publishes non-gateway node connectivity data in
`SiteNodeSlice` resources. Kubernetes remains the authoritative persisted
state. Informer caches are used to avoid API reads when cached desired and
observed state already match.

## Reconciliation Scope

Node and `SiteNodeSlice` events dirty only the affected Sites when their Site
membership is known. A reconciliation pass still evaluates Site ownership
against the complete cached Site set, but only rebuilds slices, status, and
protection finalizers for dirty Sites.

Changes with broad matching effects use a global fallback:

- Site additions, updates, and deletions
- GatewayPool selector changes
- Initial controller startup
- Events whose resource type cannot be decoded safely

This preserves correctness when a change can move nodes between Sites or
change which nodes are excluded as gateways.

## Cache and API Authority

An unchanged cache-synchronized pass performs no live Site or SiteNodeSlice
reads. Cached objects are comparison inputs only. Before a create, update, or
delete, the controller reads the live object and applies these fences:

- Site UID must still match, preventing writes for a deleted and recreated Site.
- Slice updates start from the live object, preserving metadata, finalizers,
and fields owned by other actors.
- Conflict, AlreadyExists, and NotFound responses cause a fresh live read.
- Slice deletes use live UID and resourceVersion preconditions.
- Site status and protection finalizers use live state whenever a cached
difference requires mutation.

Informer events provide eventual repair when another actor changes managed
slice fields or Site metadata.

## Orphan Repair

Orphan detection requires a live cluster-wide Site LIST and live slice reads,
so it does not run for every topology event. The controller runs it at startup
and periodically afterward.

Repair remains conservative:

- A failed Site LIST does not authorize any deletion.
- An empty Site LIST does not mean every slice is orphaned.
- Legacy net-group Sites are checked during migration.
- Orphan deletion uses live UID and resourceVersion preconditions.

Owner-reference garbage collection remains the normal path for deleted Sites.
39 changes: 25 additions & 14 deletions e2e/operator/slice_window_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ const sliceWindowClusterName = "operator-slice-window-e2e"
// re-owned and still present.
// - Phase 3 (partial migration): with the machina set non-empty, a slice whose
// Site is still only in the legacy group must survive (legacySiteExists).
// - Phase 4 (genuine orphan): a slice whose Site is absent from both groups is
// still cleaned up, proving the guard did not neuter cleanup.
// - Phase 4 (genuine orphan): after controller restart, a slice whose Site is
// absent from both groups is cleaned up by the production startup repair.
func TestSiteControllerPreservesSlicesDuringMigrationWindow(t *testing.T) {
requireBins(t, "kind", "kubectl", "docker")

Expand Down Expand Up @@ -100,6 +100,19 @@ func TestSiteControllerPreservesSlicesDuringMigrationWindow(t *testing.T) {
defer cancelController()

controllerResult := startRestrictedSiteController(controllerCtx, t, restrictedConfig)
stopController := func() {
t.Helper()
cancelController()

select {
case err := <-controllerResult:
if err != nil {
t.Fatalf("restricted SiteController stopped with error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("restricted SiteController did not stop")
}
}

// Phase 1: empty machina window. The slice must never disappear while the
// controller reconciles with zero machina Sites.
Expand All @@ -118,21 +131,19 @@ func TestSiteControllerPreservesSlicesDuringMigrationWindow(t *testing.T) {
stageLegacyOwnedSlice(ctx, t, cli, "edge-site-0", "edge-site", edgeSite.GetUID(), nodeData)
assertSliceContinuouslyPresent(controllerCtx, t, cli, "edge-site-0", 8*time.Second, controllerResult)

// Phase 4: genuine orphan. A slice whose Site exists in neither group must be
// deleted, proving the guard did not disable cleanup.
// Phase 4: restart with a genuine orphan so the startup repair removes it
// without waiting for the low-frequency periodic repair.
stopController()
stageLegacyOwnedSlice(ctx, t, cli, "ghost-0", "ghost", "", nodeData)
waitForSliceDeleted(controllerCtx, t, cli, "ghost-0", controllerResult)

cancelController()
controllerCtx, cancelController = context.WithCancel(ctx)
defer cancelController()

select {
case err := <-controllerResult:
if err != nil {
t.Fatalf("restricted SiteController stopped with error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("restricted SiteController did not stop")
}
controllerResult = startRestrictedSiteController(controllerCtx, t, restrictedConfig)
waitForSliceDeleted(controllerCtx, t, cli, "ghost-0", controllerResult)
assertSliceOwnerGroup(ctx, t, cli, "win-site-0", machinaSiteGVK.Group)
assertSliceOwnerGroup(ctx, t, cli, "edge-site-0", legacySiteGVK.Group)
stopController()
}

// firstKindNode returns the single kind node and its InternalIPs.
Expand Down
Loading
Loading