`seat <name> --to <node>/<module>` makes one assignment the holder of a seat in the same write that removes the previous one. The row is new (migration 0039); without one, the resolver derives the holder as it always did — the sole eligible assignment, two refused — so nothing changes for a mesh that never hands a seat over. With one, the recorded assignment holds and any other whose module could hold the seat is eligible and silent: not refused, not holding. That is what lets the next holder run beside the current one until the switch (hq design 26, design 28 task 5.3, ADR 0131). Why: the controller finds its own bus through a seat, and the day that seat was left with nobody in it — because two eligible holders could not coexist and the old one's claim was taken away — the control plane looped for two hours while every service stayed up. A handover that is never empty in between is the fix, not a workaround for it. `CanHold` is the one judgement of whether a module may hold a seat — claims it at its scope, provides what it delivers, against the store's row — shared by registration and the handover so they cannot drift apart. The holding belongs to the assignment and goes when it does, so a seat never points at nothing running. Tests: the resolver with and without a record, on the same and another machine, under a former name; the store's row replaced not added, refused for an unassigned target, removed with its assignment; CanHold's four answers and that they follow the store. Full suite green against a real NATS and store.
150 lines
5.5 KiB
Go
150 lines
5.5 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// HoldSeat records that one assignment holds a seat, replacing whoever held it — as one write, so
|
|
// the seat is never without a holder in between (novox/hq ADR 0131, design 28 task 5.3). The
|
|
// assignment must exist; the store refuses otherwise, and that refusal is the right one: a seat
|
|
// cannot be handed to something that is not running anywhere.
|
|
func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module string) error {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4)
|
|
on conflict (seat) do update set scope = excluded.scope, node = excluded.node,
|
|
module = excluded.module, since = now()`,
|
|
seat, scope, node.ID, module)
|
|
if err != nil {
|
|
return fmt.Errorf("recording %s on %s as the holder of %s: %w", module, nodeName, seat, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Holdings is every seat whose holder is on record, as the resolver reads it. A seat with no row here
|
|
// is held by derivation, exactly as before the table existed.
|
|
func (i *Inventory) Holdings(ctx context.Context) ([]catalogue.Held, error) {
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select h.seat, h.scope, n.name, h.module, coalesce(n.site, '')
|
|
from seat_holding h join node n on n.id = h.node order by h.seat`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []catalogue.Held
|
|
for rows.Next() {
|
|
var h catalogue.Held
|
|
if err := rows.Scan(&h.Claim, &h.Scope, &h.Node, &h.Module, &h.Site); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, h)
|
|
}
|
|
return out, rows.Err()
|
|
}
|