Merge pull request 'Seats are data the controller owns, loaded from its store (ADR 0122, phase 1)' (#82) from feat/seats-are-data into main
This commit was merged in pull request #82.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user