From 6da9a5478bcd90a220b1fe454c93bd8782a3fba8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:32:22 +0200 Subject: [PATCH] Seats keep their former names, so a rename breaks nothing (ADR 0122, phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 made the set data; a rename still broke every reference to the old name. This adds the stable identity: a seat's canonical name changes and its old name becomes an alias that resolves to it forever. SeatNamed and the holder and display matching resolve a name (former or current) to its seat, so a manifest's claim, a held record, the git-seat lookup and the build machine's embedded set all go on working unchanged after a rename. seat_alias table (migration 0035), inventory Aliases/RenameSeat, openInventory loads them, and a 'seat rename ' command does the whole thing — one operation, no rebuild, no re-registration, no freeze. Behaviour-neutral until a seat is renamed. Validated against postgres. --- cmd/mesh-controller/main.go | 2 + cmd/mesh-controller/seats.go | 29 +++++++++-- cmd/mesh-controller/stores.go | 4 ++ internal/catalogue/seats.go | 25 ++++++++- internal/catalogue/seats_test.go | 20 +++++++ .../0035-a-seat-keeps-its-former-names.sql | 12 +++++ internal/inventory/seats.go | 52 +++++++++++++++++++ internal/inventory/seats_test.go | 37 +++++++++++++ 8 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 3bcf2c1..74c5a55 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -116,6 +116,8 @@ func run() error { return pushCommand(ctx, args[1:]) case "seats": return seatsCommand(ctx, args[1:]) + case "seat": + return seatCommand(ctx, args[1:]) case "status": return statusCommand(ctx, args[1:]) case "version": diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 1a21c6b..b041c10 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -43,15 +43,16 @@ type seatRow struct { // overview would make the one thing the overview is for — what does this mesh have — quietly // incomplete. func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) { - defined := map[string]bool{} rows := make([]seatRow, 0, len(seats)) for _, s := range seats { - defined[s.Name] = true row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision, Holders: []seatHolder{}} seen := map[seatHolder]bool{} for _, h := range held { - if h.Claim != s.Name || h.Scope != s.Scope { + // Resolve the held claim to a seat rather than comparing names, so a record naming a + // seat's former name groups under it after a rename (novox/hq ADR 0122). + hs, ok := catalogue.SeatNamed(h.Claim) + if !ok || hs.Name != s.Name || h.Scope != s.Scope { continue } holder := seatHolder{Node: h.Node, Module: h.Module} @@ -70,7 +71,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata } var outside []catalogue.Held for _, h := range held { - if !defined[h.Claim] { + // Outside the set only if it resolves to no seat at all — a former name still resolves. + if _, ok := catalogue.SeatNamed(h.Claim); !ok { outside = append(outside, h) } } @@ -83,6 +85,25 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata return rows, outside } +// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122). +func seatCommand(ctx context.Context, args []string) error { + if len(args) == 3 && args[0] == "rename" { + from, to := args[1], args[2] + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + if err := open.inventory.RenameSeat(ctx, from, to); err != nil { + return err + } + fmt.Printf("%s is now %s — its former name still resolves, so nothing is rebuilt, "+ + "re-registered or frozen (novox/hq ADR 0122)\n", from, to) + return nil + } + return fmt.Errorf("seat rename ") +} + func seatsCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("seats", flag.ContinueOnError) asJSON := set.Bool("json", false, "the same, as JSON") diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index b77de10..1fa2045 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -102,6 +102,10 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) { if seats, err := inv.Seats(ctx); err == nil { catalogue.UseSeats(seats) } + // And the former names, so a reference to a seat's old name resolves after a rename (ADR 0122). + if aliases, err := inv.Aliases(ctx); err == nil { + catalogue.UseAliases(aliases) + } return inv, nil } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 47cfb9a..753bd30 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -94,18 +94,36 @@ func UseSeats(s []Seat) { } } +// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from +// the store alongside the set, so a reference to a name a seat used to have — a manifest's claim, a +// held record — still resolves to it after a rename, and nothing downstream has to change. +var aliases = map[string]string{} + +// UseAliases replaces the former-name map with the one the control plane read from its store. Empty +// is fine and ordinary: a mesh whose seats have never been renamed has no aliases. +func UseAliases(m map[string]string) { aliases = m } + // Seats is every seat the mesh defines, in reading order. func Seats() []Seat { return append([]Seat(nil), seats...) } -// SeatNamed is the seat a claim names, if the mesh defines one. +// SeatNamed is the seat a name refers to, whether that is its current name or one it used to have +// (novox/hq ADR 0122). A former name resolves to the seat's canonical row, so a rename breaks no +// reference to the old name. func SeatNamed(name string) (Seat, bool) { for _, s := range seats { if s.Name == name { return s, true } } + if canonical, aliased := aliases[name]; aliased { + for _, s := range seats { + if s.Name == canonical { + return s, true + } + } + } return Seat{}, false } @@ -211,7 +229,10 @@ func HolderAmong(provision string, providers []Provider, held []Held) (Provider, return Provider{}, false } for _, h := range held { - if h.Claim != seat.Name || h.Scope != seat.Scope { + // Resolve the held claim to a seat rather than comparing names, so a record naming a seat's + // former name still matches it after a rename (novox/hq ADR 0122). + hs, ok := SeatNamed(h.Claim) + if !ok || hs.Name != seat.Name || h.Scope != seat.Scope { continue } for _, p := range providers { diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index cef41b1..f479953 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -266,3 +266,23 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) } } + +// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim +// and a held record naming the old name break nothing after a rename. +func TestAFormerNameResolvesAfterARename(t *testing.T) { + defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }() + UseSeats([]Seat{{Name: "mesh-git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}}) + UseAliases(map[string]string{"git": "mesh-git"}) + + // The old name resolves to the renamed seat. + if s, ok := SeatNamed("git"); !ok || s.Name != "mesh-git" { + t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok) + } + // And a holder recorded under the old name is still found for the provision the seat delivers. + providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}} + held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} + holder, found := HolderAmong("git", providers, held) + if !found || holder.Module != "gitea" { + t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) + } +} diff --git a/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql b/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql new file mode 100644 index 0000000..4c3091c --- /dev/null +++ b/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql @@ -0,0 +1,12 @@ +-- A seat keeps its former names, so a rename breaks nothing (novox/hq ADR 0122). +-- +-- Phase 1 made the seat set data, but a rename still broke every reference to the old name — a +-- manifest's claim, a held record, the git-seat lookup — because they name the seat and the name +-- had changed. This is the stable identity ADR 0122 asked for, realised the simple way: a seat's +-- canonical name changes, and its old name becomes an alias that resolves to it forever. Nothing +-- downstream has to change — a manifest goes on claiming the old name, the build machine goes on +-- validating it — and a rename is one operation: set the new name, remember the old. +create table seat_alias ( + alias text primary key, -- a former name of a seat + seat text not null -- the seat's current canonical name it resolves to +); diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go index 564e578..481e6bb 100644 --- a/internal/inventory/seats.go +++ b/internal/inventory/seats.go @@ -2,6 +2,7 @@ package inventory import ( "context" + "fmt" "github.com/novox/mesh-controller/internal/catalogue" ) @@ -54,3 +55,54 @@ func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (i } return added, nil } + +// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122). +func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) { + rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`) + if err != nil { + return nil, err + } + defer rows.Close() + + aliases := map[string]string{} + for rows.Next() { + var alias, seat string + if err := rows.Scan(&alias, &seat); err != nil { + return nil, err + } + aliases[alias] = seat + } + return aliases, rows.Err() +} + +// RenameSeat gives a seat a new name and keeps the old one as an alias (novox/hq ADR 0122). +// +// **This is the whole of a rename.** The seat's canonical name becomes `to`; `from` is remembered as +// an alias so every reference to it — a manifest's claim, a held record, the build machine's +// embedded set — goes on resolving to the same seat, unchanged. Nothing is rebuilt and nothing +// freezes. Any alias that pointed to `from` is repointed to `to`, so a chain of renames does not +// leave an older name resolving to a name that no longer exists. +func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error { + if from == to { + return fmt.Errorf("a seat is renamed to a different name; %q is already its name", to) + } + tag, err := i.store.Pool().Exec(ctx, `update seat set name = $1 where name = $2`, to, from) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no seat named %q to rename", from) + } + // The old name resolves to the new one; and any name that resolved to the old one now resolves + // to the new one, so no alias is left pointing at a name that is gone. + if _, err := i.store.Pool().Exec(ctx, + `insert into seat_alias (alias, seat) values ($1, $2) + on conflict (alias) do update set seat = excluded.seat`, from, to); err != nil { + return err + } + if _, err := i.store.Pool().Exec(ctx, + `update seat_alias set seat = $1 where seat = $2`, to, from); err != nil { + return err + } + return nil +} diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go index 94ccda5..e8a7135 100644 --- a/internal/inventory/seats_test.go +++ b/internal/inventory/seats_test.go @@ -76,3 +76,40 @@ func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) { } } } + +// A rename is one operation: the seat gets the new name, the old name becomes an alias that still +// resolves to it (novox/hq ADR 0122). +func TestRenameSeatKeepsTheFormerNameAsAnAlias(t *testing.T) { + inv := ForTest(t) + if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + if err := inv.RenameSeat(t.Context(), "node-packet-filter", "node-firewall"); err != nil { + t.Fatal(err) + } + seats, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, s := range seats { + names[s.Name] = true + } + if !names["node-firewall"] || names["node-packet-filter"] { + t.Fatalf("the seat was not renamed in place: %v", names) + } + aliases, err := inv.Aliases(t.Context()) + if err != nil { + t.Fatal(err) + } + if aliases["node-packet-filter"] != "node-firewall" { + t.Fatalf("the former name is not an alias of the new one: %v", aliases) + } + // Renaming what has no seat is refused; renaming to the same name is refused. + if err := inv.RenameSeat(t.Context(), "no-such-seat", "x"); err == nil { + t.Fatal("renaming a seat that does not exist was accepted") + } + if err := inv.RenameSeat(t.Context(), "node-firewall", "node-firewall"); err == nil { + t.Fatal("renaming a seat to its own name was accepted") + } +}