From 2ec0fd218b129656be05a7f81b4f32ea7e2a8b31 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:04:44 +0200 Subject: [PATCH] Seats are data the controller owns, loaded from its store (ADR 0122, phase 1) The seat set was a Go slice compiled into the controller and referenced by name everywhere, so changing it meant a rebuild and a freeze-prone deploy. It is now a table: catalogue keeps the shipped set as defaultSeats (the seed and the fallback) and a loadable working set; inventory adds the seat table (migration 0034), Seats to read it, and SeedSeats to fill it idempotently without overwriting an operator's edit; migrate seeds it; openInventory loads it, and an empty or unreadable table leaves the compiled defaults in force so it can never brick the control plane's boot. Behaviour-neutral: the seeded table equals the defaults. Phase 2 (reference by a stable id so a rename touches no manifest or code, and the builder reads the set from the mesh) follows. --- cmd/mesh-controller/stores.go | 17 ++++ internal/catalogue/seats.go | 29 ++++++- internal/catalogue/seats_test.go | 20 +++++ .../migrations/0034-the-seats-are-data.sql | 17 ++++ internal/inventory/seats.go | 56 +++++++++++++ internal/inventory/seats_test.go | 78 +++++++++++++++++++ 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 internal/inventory/migrations/0034-the-seats-are-data.sql create mode 100644 internal/inventory/seats.go create mode 100644 internal/inventory/seats_test.go diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index 8a46256..b77de10 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/licences" @@ -72,6 +73,14 @@ func migrate(ctx context.Context) error { } fmt.Printf("provided %s\n", m.Module) } + // The seats the mesh ships with, into the table that now holds the set (novox/hq ADR 0122). + // Idempotent: fills an empty table on first boot, adds a seat a release ships, and leaves an + // operator's changes in the table as they are. + added, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()) + if err != nil { + return err + } + fmt.Printf("seeded %d seat(s)\n", added) return nil } @@ -85,6 +94,14 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) { inv.Close() return nil, err } + // Load the seat set from the store, so the control plane reads the set as data rather than as + // the slice it was compiled with (novox/hq ADR 0122). A store not yet seeded — or one whose + // seat table a migration has not reached — returns nothing, and UseSeats leaves the compiled + // defaults in force: the set is never emptied by a read that found nothing, which would refuse + // every claim. So this can only ever replace the defaults with what the mesh actually holds. + if seats, err := inv.Seats(ctx); err == nil { + catalogue.UseSeats(seats) + } return inv, nil } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index f1e548b..47cfb9a 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -31,8 +31,12 @@ type Seat struct { Decision string } -// seats is the whole set, in the order a person reads it: the mesh's own, then a node's. -var seats = []Seat{ +// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the +// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is +// written; the store's table is seeded from it and thereafter is the live, editable copy. +// +// In the order a person reads it: the mesh's own, then a node's. +var defaultSeats = []Seat{ {Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"}, {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "novox/hq ADR 0079"}, @@ -69,6 +73,27 @@ func isSystemSeatName(name string) bool { return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") } +// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by +// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a +// change to data, not to this code. +var seats = defaultSeats + +// DefaultSeats is the set the mesh ships with, for seeding the store's seat table. +func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } + +// UseSeats replaces the working set with the one the control plane read from its store. +// +// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be +// read — must leave the compiled defaults in force rather than emptying the set: an empty set would +// refuse every claim and could stop the control plane composing at all, which is a far worse failure +// than running on the set the binary shipped with. So the store can only ever *replace* the set with +// a non-empty one, never erase it. +func UseSeats(s []Seat) { + if len(s) > 0 { + seats = s + } +} + // Seats is every seat the mesh defines, in reading order. func Seats() []Seat { return append([]Seat(nil), seats...) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 0c91ebf..cef41b1 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -246,3 +246,23 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) } } + +// The working set is loaded from the store, and an empty load never erases it (novox/hq ADR 0122). +func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { + before := Seats() + defer UseSeats(DefaultSeats()) // restore for other tests, whatever this leaves it as + + // An empty load (store not seeded, or unreadable) leaves the compiled defaults in force. + UseSeats(nil) + if len(Seats()) != len(before) { + t.Fatalf("an empty load changed the set from %d to %d seats", len(before), len(Seats())) + } + // A non-empty load replaces it — this is how a rename in the store reaches the lookups. + UseSeats([]Seat{{Name: "node-firewall", Scope: ScopeNode, Decision: "novox/hq ADR 0122"}}) + if _, known := SeatNamed("node-firewall"); !known { + t.Fatal("the loaded set did not replace the working set") + } + if len(Seats()) != 1 { + t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) + } +} diff --git a/internal/inventory/migrations/0034-the-seats-are-data.sql b/internal/inventory/migrations/0034-the-seats-are-data.sql new file mode 100644 index 0000000..d051889 --- /dev/null +++ b/internal/inventory/migrations/0034-the-seats-are-data.sql @@ -0,0 +1,17 @@ +-- The seats are data the control plane owns, not a slice compiled into it (novox/hq ADR 0122). +-- +-- Until this, the closed set 0110 defines lived only as a Go slice, referenced by name everywhere, +-- so renaming a seat or adding one meant a controller rebuild and a mesh-wide, freeze-prone deploy. +-- The set is now a table: one row per seat, seeded from the binary's defaults the first time the +-- control plane comes up, and thereafter the live copy the control plane reads and an operator can +-- change. A rename becomes an update here rather than a release. +-- +-- The name is the key for now, because claims and held records still reference a seat by name; the +-- move to a stable id that a rename does not touch is the next step (ADR 0122). `delivers` is empty +-- for a seat that answers for no provision, matching the compiled default. +create table seat ( + name text primary key, + scope text not null, + delivers text not null default '', + decided text not null +); diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go new file mode 100644 index 0000000..564e578 --- /dev/null +++ b/internal/inventory/seats.go @@ -0,0 +1,56 @@ +package inventory + +import ( + "context" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The seats the mesh has, as data (novox/hq ADR 0122). +// +// The set the control plane reads is a table here, not a slice compiled into it. It is seeded from +// the binary's defaults the first time the mesh comes up (SeedSeats), and thereafter it is the live +// copy: a rename or an added seat is a write here, and the control plane loads it at startup rather +// than being rebuilt for it. + +// Seats is every seat the mesh defines, read from the store. +func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) { + rows, err := i.store.Pool().Query(ctx, + `select name, scope, delivers, decided from seat order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var seats []catalogue.Seat + for rows.Next() { + var s catalogue.Seat + if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil { + return nil, err + } + seats = append(seats, s) + } + return seats, rows.Err() +} + +// SeedSeats writes the mesh's default set into the table where it is not already present. +// +// **Idempotent, and never overwriting.** Run every time the control plane migrates, it fills an +// empty table on first boot and adds a seat a new release ships — but it leaves a row already there +// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting +// the old name back. What a release removes from the defaults is not deleted here either; retiring a +// seat is its own decision, not a silent consequence of it dropping out of the binary. +func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { + var added int + for _, s := range defaults { + tag, err := i.store.Pool().Exec(ctx, + `insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4) + on conflict (name) do nothing`, + s.Name, s.Scope, s.Delivers, s.Decision) + if err != nil { + return added, err + } + added += int(tag.RowsAffected()) + } + return added, nil +} diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go new file mode 100644 index 0000000..94ccda5 --- /dev/null +++ b/internal/inventory/seats_test.go @@ -0,0 +1,78 @@ +package inventory + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The seat set is data the control plane owns (novox/hq ADR 0122): seeded from the binary's +// defaults, read back as the working set, and thereafter an operator's to change without a rebuild. + +func TestSeatsAreSeededFromTheDefaultsAndReadBack(t *testing.T) { + inv := ForTest(t) + defaults := catalogue.DefaultSeats() + + added, err := inv.SeedSeats(t.Context(), defaults) + if err != nil { + t.Fatal(err) + } + if added != len(defaults) { + t.Fatalf("seeded %d of %d seats", added, len(defaults)) + } + got, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(got) != len(defaults) { + t.Fatalf("read back %d seats, seeded %d", len(got), len(defaults)) + } + // The set round-trips: name, scope and what it delivers survive the store. + by := map[string]catalogue.Seat{} + for _, s := range got { + by[s.Name] = s + } + for _, d := range defaults { + if by[d.Name].Scope != d.Scope || by[d.Name].Delivers != d.Delivers { + t.Errorf("%s came back as %+v, seeded %+v", d.Name, by[d.Name], d) + } + } +} + +// Re-seeding an already-seeded set adds nothing and changes nothing — every deploy runs SeedSeats, +// and a mesh already holding the set must be left exactly as it is (an operator's edit to a row +// included). A row's fields are not overwritten: on conflict the insert does nothing. +// +// (Phase 1 keys the table by name, so a seat *renamed* in the table would have its old name +// re-seeded — the move to a stable id a rename does not touch is the next step, ADR 0122. This test +// asserts only the property that holds now: an unchanged set re-seeds to a no-op.) +func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) { + inv := ForTest(t) + defaults := catalogue.DefaultSeats() + if _, err := inv.SeedSeats(t.Context(), defaults); err != nil { + t.Fatal(err) + } + + // An operator changes a row's scope in the table — the point of it being data. + if _, err := inv.store.Pool().Exec(t.Context(), + `update seat set scope = 'mesh' where name = 'node-uplink'`); err != nil { + t.Fatal(err) + } + + added, err := inv.SeedSeats(t.Context(), defaults) + if err != nil { + t.Fatal(err) + } + if added != 0 { + t.Fatalf("re-seeding an already-present set added %d rows", added) + } + got, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, s := range got { + if s.Name == "node-uplink" && s.Scope != "mesh" { + t.Fatalf("re-seeding overwrote the operator's change: node-uplink scope is %q", s.Scope) + } + } +} -- 2.54.0