Seats are data the controller owns, loaded from its store (ADR 0122, phase 1) #82

Merged
jschoubben merged 1 commits from feat/seats-are-data into main 2026-09-27 14:05:40 +00:00
6 changed files with 215 additions and 2 deletions
+17
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/identity"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences" "github.com/novox/mesh-controller/internal/licences"
@@ -72,6 +73,14 @@ func migrate(ctx context.Context) error {
} }
fmt.Printf("provided %s\n", m.Module) 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 return nil
} }
@@ -85,6 +94,14 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) {
inv.Close() inv.Close()
return nil, err 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 return inv, nil
} }
+27 -2
View File
@@ -31,8 +31,12 @@ type Seat struct {
Decision string Decision string
} }
// seats is the whole set, in the order a person reads it: the mesh's own, then a node's. // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
var seats = []Seat{ // 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-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"},
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", 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"}, {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-") 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. // Seats is every seat the mesh defines, in reading order.
func Seats() []Seat { func Seats() []Seat {
return append([]Seat(nil), seats...) return append([]Seat(nil), seats...)
+20
View File
@@ -246,3 +246,23 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) {
t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) 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()))
}
}
@@ -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
);
+56
View File
@@ -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
}
+78
View File
@@ -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)
}
}
}