musl takes the first reply from any listed nameserver, so a public fallback beside the mesh's resolver answered NXDOMAIN for mesh names in every Alpine container (hq ADR 0223). The fix is two mesh resolvers and no public one, which needs mesh-dns-resolver held on two machines: a seat can now be replicated, each holder recorded by 'seat <name> --add', checkClaims accepts every holder on record and still refuses a second holder of any other mesh seat, a holder answers its own requirement, and a roster fact gives each replicated seat's holders, this machine first, so resolv-conf can list them. Migration 0062 keys a holding by seat and assignment.
294 lines
10 KiB
Go
294 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
)
|
|
|
|
// What this mesh can have one of, and who fills each (novox/hq ADR 0110).
|
|
//
|
|
// **Derived every time, never stored.** A seat is held by a module assignment, so the answer is
|
|
// computed from assignments by the same resolution that decides what every machine runs. A table
|
|
// of holders kept beside the assignments would be a second copy of one fact, and the first thing
|
|
// to be wrong about it.
|
|
|
|
// seatHolder is one assignment holding a seat.
|
|
type seatHolder struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
}
|
|
|
|
// seatRow is one seat and who holds it. Unheld is an answer — "this mesh has no X" — not a fault.
|
|
type seatRow struct {
|
|
Seat string `json:"seat"`
|
|
Scope string `json:"scope"`
|
|
Delivers string `json:"delivers,omitempty"`
|
|
Decision string `json:"decision"`
|
|
// Replicated says the seat may be held on several machines at once (novox/hq ADR 0223).
|
|
Replicated bool `json:"replicated,omitempty"`
|
|
Holders []seatHolder `json:"holders"`
|
|
}
|
|
|
|
// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no
|
|
// seat in the set.
|
|
//
|
|
// **The second list is not empty by construction.** Manifests are held to the set when they are
|
|
// registered, and a mesh can hold one registered before the set closed. Leaving its claim out of the
|
|
// 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) {
|
|
rows := make([]seatRow, 0, len(seats))
|
|
for _, s := range seats {
|
|
row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision,
|
|
Replicated: s.Replicated, Holders: []seatHolder{}}
|
|
seen := map[seatHolder]bool{}
|
|
for _, h := range held {
|
|
// 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}
|
|
if !seen[holder] {
|
|
seen[holder] = true
|
|
row.Holders = append(row.Holders, holder)
|
|
}
|
|
}
|
|
sort.Slice(row.Holders, func(i, j int) bool {
|
|
if row.Holders[i].Node != row.Holders[j].Node {
|
|
return row.Holders[i].Node < row.Holders[j].Node
|
|
}
|
|
return row.Holders[i].Module < row.Holders[j].Module
|
|
})
|
|
rows = append(rows, row)
|
|
}
|
|
var outside []catalogue.Held
|
|
for _, h := range held {
|
|
// 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)
|
|
}
|
|
}
|
|
sort.Slice(outside, func(i, j int) bool {
|
|
if outside[i].Claim != outside[j].Claim {
|
|
return outside[i].Claim < outside[j].Claim
|
|
}
|
|
return outside[i].Node < outside[j].Node
|
|
})
|
|
return rows, outside
|
|
}
|
|
|
|
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122) — and, since
|
|
// ADR 0131, changes who holds a seat.
|
|
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
|
|
}
|
|
if len(args) == 3 && args[1] == "--to" {
|
|
return handOver(ctx, args[0], args[2], false)
|
|
}
|
|
if len(args) == 3 && args[1] == "--add" {
|
|
return handOver(ctx, args[0], args[2], true)
|
|
}
|
|
return fmt.Errorf("seat rename <from> <to> | seat <name> --to <node>/<module> | " +
|
|
"seat <name> --add <node>/<module>")
|
|
}
|
|
|
|
// handOver makes one assignment the holder of a seat, as one act, so the seat is never without a
|
|
// holder in between (novox/hq ADR 0131, design 28 task 5.3). The control plane finds its own bus
|
|
// through one of these seats; the day it was left empty mid-change is why this exists.
|
|
//
|
|
// Everything that could make the new holder wrong is refused here, before the row is written: the
|
|
// seat must exist, the assignment must exist, and the module must be able to hold the seat —
|
|
// claim it at its scope and provide what it delivers, judged against the store's row. What is
|
|
// **not** checked is whether the module is running yet: that is what `push` confirms afterwards,
|
|
// and refusing to record a handover to a module the node has not started would make the handover
|
|
// impossible to do before the switch instead of as the switch.
|
|
//
|
|
// **Or adds one holder beside the others, for a replicated seat** (novox/hq ADR 0223): `--add`
|
|
// records the named assignment as a further holder and leaves every holder on record as it is. A
|
|
// seat held once refuses it, naming `--to`; `--to` on a replicated seat replaces every holder with
|
|
// the one named, as it always did.
|
|
func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
|
nodeName, module, ok := strings.Cut(to, "/")
|
|
if !ok || nodeName == "" || module == "" {
|
|
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
seat, known := catalogue.SeatNamed(seatName)
|
|
if !known {
|
|
return fmt.Errorf("%q is not a seat this mesh defines — `seats` lists them", seatName)
|
|
}
|
|
if adding && !seat.Replicated {
|
|
return fmt.Errorf("%s is held once per %s, so a second holder cannot be added beside the first — "+
|
|
"`seat %s --to %s` hands it over", seat.Name, seat.Scope, seat.Name, to)
|
|
}
|
|
assigned, err := inv.Assigned(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.Contains(assigned, module) {
|
|
return fmt.Errorf("%s is not assigned to %s, so it cannot hold anything there — "+
|
|
"`assign %s %s` first", module, nodeName, nodeName, module)
|
|
}
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var m *catalogue.Manifest
|
|
for i := range entries {
|
|
if entries[i].Manifest.Module == module {
|
|
m = &entries[i].Manifest
|
|
}
|
|
}
|
|
if m == nil {
|
|
return fmt.Errorf("%s is assigned but not in the catalogue, which should not happen", module)
|
|
}
|
|
var was string
|
|
var held []string
|
|
holdings, err := inv.Holdings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, h := range holdings {
|
|
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name {
|
|
was = h.Node
|
|
held = append(held, h.Node)
|
|
}
|
|
}
|
|
|
|
// **Recording who already holds the seat is not making a new holder, and is not judged like
|
|
// one.** On a mesh that predates the record, the first handover has to begin by writing down
|
|
// the standing holder — otherwise the next holder cannot be assigned beside it, because two
|
|
// eligible claimants with nothing on record are refused. That standing holder may no longer
|
|
// satisfy what the seat delivers (the row moved under it, on purpose, as ADR 0131's first step),
|
|
// and it holds regardless: derivation never read that column. So when nothing is on record and
|
|
// the named assignment is the one holding by derivation, only the claim itself is checked here.
|
|
// Every *change* of holder is judged in full.
|
|
claimsIt := false
|
|
for _, c := range m.Claims {
|
|
if cs, ok := catalogue.SeatNamed(c.Name); ok && cs.Name == seat.Name && c.At() == seat.Scope {
|
|
claimsIt = true
|
|
}
|
|
}
|
|
if was == "" && claimsIt {
|
|
fmt.Printf("nothing was on record for %s; recording %s on %s as its standing holder\n",
|
|
seat.Name, module, nodeName)
|
|
} else if err := catalogue.CanHold(*m, seat); err != nil {
|
|
return fmt.Errorf("%s cannot hold %s: %w", module, seat.Name, err)
|
|
}
|
|
if adding {
|
|
if err := inv.AddSeatHolder(ctx, seat.Name, seat.Scope, nodeName, module); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s is held by %s on %s, beside what was on record: %s\n", seat.Name, module, nodeName,
|
|
strings.Join(held, ", "))
|
|
fmt.Printf(" `push --behind` re-declares every machine that reads the seat's holders\n")
|
|
return nil
|
|
}
|
|
if err := inv.HoldSeat(ctx, seat.Name, seat.Scope, nodeName, module); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s is held by %s on %s\n", seat.Name, module, nodeName)
|
|
if was != "" && was != nodeName {
|
|
fmt.Printf(" `push %s` and `push %s` send both machines what changed\n", was, nodeName)
|
|
} else {
|
|
fmt.Printf(" `push %s` sends the machine what changed; every other machine that reads the "+
|
|
"seat is re-declared by `push --behind`\n", nodeName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func seatsCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("seats", flag.ContinueOnError)
|
|
asJSON := set.Bool("json", false, "the same, as JSON")
|
|
if err := set.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
shelf, err := inv.Catalogue(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Every node, none excluded: the same view of what each machine holds that planning uses.
|
|
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, outside := seatsHeld(catalogue.Seats(), world.Held)
|
|
|
|
if *asJSON {
|
|
out := struct {
|
|
Seats []seatRow `json:"seats"`
|
|
Outside []catalogue.Held `json:"outside,omitempty"`
|
|
}{rows, outside}
|
|
body, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(string(body))
|
|
return nil
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY")
|
|
for _, r := range rows {
|
|
delivers := r.Delivers
|
|
if delivers == "" {
|
|
delivers = "—"
|
|
}
|
|
holders := "unheld"
|
|
if len(r.Holders) > 0 {
|
|
parts := make([]string, 0, len(r.Holders))
|
|
for _, h := range r.Holders {
|
|
parts = append(parts, h.Module+" on "+h.Node)
|
|
}
|
|
holders = strings.Join(parts, ", ")
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders)
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
if len(outside) > 0 {
|
|
fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):")
|
|
for _, h := range outside {
|
|
fmt.Printf(" %s %s on %s\n", h.Claim, h.Module, h.Node)
|
|
}
|
|
}
|
|
return nil
|
|
}
|