Seats keep their former names — a rename is one DB op (ADR 0122, phase 2) #83

Merged
jschoubben merged 1 commits from feat/seats-rename-is-a-db-op into main 2026-09-27 14:34:45 +00:00
8 changed files with 175 additions and 6 deletions
+2
View File
@@ -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":
+25 -4
View File
@@ -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 <from> <to>")
}
func seatsCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("seats", flag.ContinueOnError)
asJSON := set.Bool("json", false, "the same, as JSON")
+4
View File
@@ -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
}
+23 -2
View File
@@ -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 {
+20
View File
@@ -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)
}
}
@@ -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
);
+52
View File
@@ -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
}
+37
View File
@@ -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")
}
}