Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b36babcd7d | ||
|
|
49cf0aa562 | ||
|
|
5a4f73f761 | ||
|
|
6af891e358 | ||
|
|
568f55fd2e | ||
|
|
35314175f2 | ||
|
|
ec2e6255a9 | ||
|
|
f3f34a170e | ||
|
|
e2622fd031 | ||
|
|
3ee32970ef | ||
|
|
11b654499b | ||
|
|
d69e19103c | ||
|
|
10f948e970 | ||
|
|
f421d4588c |
+147
-22
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -39,7 +40,10 @@ import (
|
||||
//
|
||||
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
||||
// machines this just blocked is worth more than the milliseconds.
|
||||
func assign(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||
func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||
if len(modules) == 0 {
|
||||
return "", fmt.Errorf("assign %s names no module", node)
|
||||
}
|
||||
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
||||
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||
@@ -47,6 +51,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
|
||||
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
|
||||
// resources are applied through a seat nothing on the node holds is refused, because that order
|
||||
// — the service manager, the package manager and the runtime before anything that installs,
|
||||
// runs or contains — is the mesh's to keep. Several modules in one act are judged together, so
|
||||
// holders that depend on each other go on in one command.
|
||||
shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
||||
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
||||
// assignment resolves against a record rather than against a coincidence.
|
||||
@@ -54,65 +68,176 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var lines []string
|
||||
var added []string
|
||||
for _, module := range modules {
|
||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||
if err != nil {
|
||||
return strings.Join(lines, "\n"), err
|
||||
}
|
||||
if !fresh {
|
||||
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
||||
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module))
|
||||
continue
|
||||
}
|
||||
added = append(added, module)
|
||||
lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module))
|
||||
}
|
||||
if !fresh {
|
||||
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
||||
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
||||
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
|
||||
node, module), nil
|
||||
if len(added) == 0 {
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||
answer := strings.Join(lines, "\n")
|
||||
for _, line := range settled {
|
||||
said += "\n " + line
|
||||
answer += "\n " + line
|
||||
}
|
||||
// What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq
|
||||
// ADR 0207): a dependency of a module just assigned, or one this assignment met. The rest of the
|
||||
// node's list, and every other node's, is `status`'s.
|
||||
for _, line := range unheldChange(shelf, node, before, append(append([]string(nil), before...), added...)) {
|
||||
answer += "\n " + line
|
||||
}
|
||||
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
||||
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
||||
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
||||
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
||||
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
||||
said += "\n " + line
|
||||
for _, module := range added {
|
||||
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
||||
answer += "\n " + line
|
||||
}
|
||||
}
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||
// worth reporting: this machine's refusal is rarely the only consequence.
|
||||
return said + blockedElsewhere(ctx, open, node), err
|
||||
return answer + blockedElsewhere(ctx, open, node), err
|
||||
}
|
||||
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
||||
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
||||
// meant while this line says it will not run until it moves. The rest of the node still pushes.
|
||||
isAdded := map[string]bool{}
|
||||
for _, m := range added {
|
||||
isAdded[m] = true
|
||||
}
|
||||
for _, u := range plan.Unhostable {
|
||||
if u.Module != module {
|
||||
if !isAdded[u.Module] {
|
||||
continue
|
||||
}
|
||||
for _, c := range u.Missing {
|
||||
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||
answer += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||
}
|
||||
}
|
||||
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||
return answer + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||
blockedElsewhere(ctx, open, node), nil
|
||||
}
|
||||
|
||||
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
|
||||
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the
|
||||
// catalogue and the node's assignments it was judged against. Modules already assigned are not new
|
||||
// and are not judged again.
|
||||
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) (
|
||||
map[string]catalogue.Manifest, []string, error) {
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
assigned, err := open.inventory.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
already := map[string]bool{}
|
||||
for _, a := range assigned {
|
||||
already[a] = true
|
||||
}
|
||||
var adding []string
|
||||
for _, m := range modules {
|
||||
if !already[m] {
|
||||
adding = append(adding, m)
|
||||
}
|
||||
}
|
||||
// The lines AssignRefusal says beside an assignment it lets through are said by unheldChange
|
||||
// with everything else this act changed, so they are not said twice.
|
||||
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Two modules declaring one package, path or unit is refused before anything is recorded
|
||||
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
|
||||
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return shelf, assigned, nil
|
||||
}
|
||||
|
||||
// unassign takes modules off a node. What they leave behind is the host's business: a directory
|
||||
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
||||
//
|
||||
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
||||
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
||||
// about the command's own output would ever have said so.
|
||||
func unassign(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||
//
|
||||
// **Refused when it takes away the last holder of a seat a module left on the node depends on**
|
||||
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
|
||||
// modules in one act are judged together, so a holder and its dependents come off in one command.
|
||||
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||
if len(modules) == 0 {
|
||||
return "", fmt.Errorf("unassign %s names no module", node)
|
||||
}
|
||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||
node, module, node) + blockedElsewhere(ctx, open, node), nil
|
||||
assigned, err := open.inventory.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Every one checked before any is taken off, so a refusal leaves the node as it was.
|
||||
runs := map[string]bool{}
|
||||
for _, a := range assigned {
|
||||
runs[a] = true
|
||||
}
|
||||
for _, module := range modules {
|
||||
if !runs[module] {
|
||||
return "", fmt.Errorf("%s is not assigned to %s", module, node)
|
||||
}
|
||||
}
|
||||
if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, module := range modules {
|
||||
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||
node, strings.Join(modules, ", "), node)
|
||||
var left []string
|
||||
for _, a := range assigned {
|
||||
if !slices.Contains(modules, a) {
|
||||
left = append(left, a)
|
||||
}
|
||||
}
|
||||
for _, line := range unheldChange(shelf, node, assigned, left) {
|
||||
answer += "\n " + line
|
||||
}
|
||||
return answer + blockedElsewhere(ctx, open, node), nil
|
||||
}
|
||||
|
||||
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
|
||||
// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the
|
||||
// command API and the controller seat's verbs as they do from the command line.
|
||||
func splitModules(field string) []string {
|
||||
var out []string
|
||||
for _, m := range strings.Split(field, ",") {
|
||||
if m = strings.TrimSpace(m); m != "" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
||||
|
||||
@@ -95,10 +95,10 @@ func commands(who Authenticator) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return assign(ctx, open, in.Node, in.Module)
|
||||
return assign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||
}))
|
||||
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return unassign(ctx, open, in.Node, in.Module)
|
||||
return unassign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||
}))
|
||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
|
||||
@@ -650,6 +650,11 @@ type answers struct {
|
||||
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
||||
// machine's own state file, and the one visible symptom was a count that did not add up.
|
||||
untaken map[string]map[string]int
|
||||
// unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||
// that machine holds (novox/hq ADR 0207), with the modules that could hold it. Reported, not
|
||||
// refused, until the switch — and while there is any, the mesh is not all well: the order the
|
||||
// machines' modules are built in is the mesh's to keep, and this is where it says it is not kept.
|
||||
unheld []catalogue.Unheld
|
||||
}
|
||||
|
||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||
|
||||
@@ -179,8 +179,8 @@ func usage() {
|
||||
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||
assign <node> <module> put a module on a node
|
||||
unassign <node> <module> take it off
|
||||
assign <node> <module>... put modules on a node, judged together (ADR 0207)
|
||||
unassign <node> <module>... take them off
|
||||
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
||||
what it declares; --yes <digest> cuts it over as previewed
|
||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||
|
||||
@@ -334,8 +334,10 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("%s <node> <module>", verb)
|
||||
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
|
||||
// service manager and the package manager — can only go on, or come off, together.
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -349,7 +351,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
if verb == "unassign" {
|
||||
act = unassign
|
||||
}
|
||||
said, err := act(ctx, open, args[0], args[1])
|
||||
said, err := act(ctx, open, args[0], args[1:]...)
|
||||
if said != "" {
|
||||
fmt.Println(said)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -127,6 +129,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||
}
|
||||
logUnheld(nodeName, resolved.Unheld)
|
||||
|
||||
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
||||
// password a provider is told to create is the one its consumer was given — and sealed to
|
||||
@@ -1325,3 +1328,90 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says
|
||||
// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document.
|
||||
//
|
||||
// **The serving controller's log only.** planFor runs for every node on every push, assignment and
|
||||
// status — `blockedElsewhere` alone resolves the whole mesh — and a one-shot command starts with an
|
||||
// empty memory, so every node's report was "a change" and a push printed the whole mesh's list,
|
||||
// burying the line about the node it acted on. A command says what concerns its own act instead
|
||||
// (unheldChange, reportUnheldPushed); the full list is `status`'s.
|
||||
var (
|
||||
unheldLogged = map[string]string{}
|
||||
unheldLoggedMu sync.Mutex
|
||||
logUnheldChanges bool
|
||||
)
|
||||
|
||||
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
|
||||
// it, including when they become none — in the serving controller, and nowhere else.
|
||||
func logUnheld(node string, unheld []catalogue.Unheld) {
|
||||
if !logUnheldChanges {
|
||||
return
|
||||
}
|
||||
lines := make([]string, 0, len(unheld))
|
||||
for _, u := range unheld {
|
||||
lines = append(lines, u.String())
|
||||
}
|
||||
now := strings.Join(lines, "\n")
|
||||
unheldLoggedMu.Lock()
|
||||
before, seen := unheldLogged[node]
|
||||
unheldLogged[node] = now
|
||||
unheldLoggedMu.Unlock()
|
||||
if (seen && before == now) || (!seen && now == "") {
|
||||
return
|
||||
}
|
||||
if now == "" {
|
||||
fmt.Fprintf(os.Stderr, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
|
||||
node, len(lines), strings.Join(lines, "\n "))
|
||||
}
|
||||
|
||||
// unheldChange is what an act on one node changed about its unmet seat dependencies, judged over
|
||||
// its assignments before and after (novox/hq ADR 0207): each dependency now unmet that was not —
|
||||
// which includes every one of a module just assigned — and each now met that was not. Nothing about
|
||||
// any other node, and nothing that was already true before the act.
|
||||
func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string {
|
||||
judge := func(names []string) map[string]catalogue.Unheld {
|
||||
var set []catalogue.Manifest
|
||||
for _, n := range names {
|
||||
if m, known := shelf[n]; known {
|
||||
set = append(set, m)
|
||||
}
|
||||
}
|
||||
out := map[string]catalogue.Unheld{}
|
||||
for _, u := range catalogue.UnheldDependencies(shelf, node, set, nil) {
|
||||
out[u.Module+" "+u.Seat] = u
|
||||
}
|
||||
return out
|
||||
}
|
||||
was, now := judge(before), judge(after)
|
||||
var lines []string
|
||||
for _, k := range sortedNames(now) {
|
||||
if _, already := was[k]; !already {
|
||||
lines = append(lines, "but "+now[k].String())
|
||||
}
|
||||
}
|
||||
for _, k := range sortedNames(was) {
|
||||
if _, still := now[k]; still {
|
||||
continue
|
||||
}
|
||||
u := was[k]
|
||||
if !slices.Contains(after, u.Module) {
|
||||
continue // went with its module, which says nothing about the seat
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("and %s on %s now has %s held", u.Module, node, u.Seat))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func sortedNames[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -62,6 +63,9 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
|
||||
}
|
||||
|
||||
func serve(ctx context.Context) error {
|
||||
// The one process whose log is read over time, so the one that says each change to a node's
|
||||
// unmet seat dependencies once (novox/hq ADR 0207).
|
||||
logUnheldChanges = true
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -360,6 +364,9 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Each machine's unmet seat dependencies (novox/hq ADR 0207), said after the sends: in full
|
||||
// for a machine named, as a count for each of many — the full list is `status`'s.
|
||||
unheld := map[string][]catalogue.Unheld{}
|
||||
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
||||
plan, settings, err := planFor(held, open, node)
|
||||
if err != nil {
|
||||
@@ -369,6 +376,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
// healthy modules beside it are still resolved and sent. Reported so it is not silently
|
||||
// dropped — the remedy is to move it, and until then the rest of the node converges.
|
||||
reportUnhostable(node, plan)
|
||||
unheld[node] = plan.Unheld
|
||||
// The private network is in here with everything else. It used to be composed separately
|
||||
// and prepended, which meant every machine with an address was on it and no machine could
|
||||
// be kept off. It is a module now, so it arrives the way a module does.
|
||||
@@ -402,6 +410,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
release()
|
||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||
reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld)
|
||||
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
||||
// operators run, and on 2026-10-01 it was the one path that issued none.
|
||||
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
||||
@@ -1022,3 +1031,24 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// reportUnheldPushed says what a push's machines lack of the seats their modules depend on
|
||||
// (novox/hq ADR 0207): every line for a machine the push named, since that is the machine somebody
|
||||
// is looking at, and one line per machine otherwise — a list per machine across the mesh is the
|
||||
// hundred lines that buried the one that mattered. Nothing for a machine that lacks nothing.
|
||||
func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[string][]catalogue.Unheld) {
|
||||
for _, node := range asked {
|
||||
lines := unheld[node]
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
if named {
|
||||
fmt.Fprintf(w, "\n%s has %d unmet seat dependenc(ies) (novox/hq ADR 0207):\n", node, len(lines))
|
||||
for _, u := range lines {
|
||||
fmt.Fprintf(w, " %s\n", u)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -73,6 +74,10 @@ type meshStatus struct {
|
||||
// alone. A document without this called a machine well while a predecessor's chain refused
|
||||
// what the mesh declared open.
|
||||
Filtered []machineFiltered `json:"filtered,omitempty"`
|
||||
// Unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||
// that machine holds, with the modules that could hold it (novox/hq ADR 0207). Absent when every
|
||||
// dependency is met. Reported, not refused, until the switch.
|
||||
Unheld []catalogue.Unheld `json:"unheld,omitempty"`
|
||||
}
|
||||
|
||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||
@@ -204,6 +209,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
||||
}
|
||||
}
|
||||
out.Unheld = asked.unheld
|
||||
for name := range asked.refused {
|
||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||
Node: name, Problem: asked.refused[name]})
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0207 at the controller's acts: `assign` refuses a module whose resources a
|
||||
// seat nothing on the node holds applies, `unassign` refuses taking the last holder from under its
|
||||
// dependents, and `status` reports what composition does not yet refuse.
|
||||
|
||||
func serviceManagerHolder() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "systemd", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode,
|
||||
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}}},
|
||||
Resources: []map[string]any{{"id": "systemd", "type": "package", "package": "systemd"}}}
|
||||
}
|
||||
|
||||
func packageManagerHolder() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "pacman", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode}},
|
||||
Resources: []map[string]any{{"id": "refresh", "type": "service", "unit": "pacman-refresh.timer"}}}
|
||||
}
|
||||
|
||||
func aDaemon() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "sshd", Version: "1",
|
||||
Resources: []map[string]any{{"id": "sshd", "type": "service", "unit": "sshd.service"}}}
|
||||
}
|
||||
|
||||
func TestAnAssignmentWithoutItsHolderIsRefusedAndNotKept(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, serviceManagerHolder())
|
||||
register(t, open, packageManagerHolder())
|
||||
register(t, open, aDaemon())
|
||||
|
||||
_, err := assign(ctx, open, "laptop", "sshd")
|
||||
if err == nil {
|
||||
t.Fatal("sshd went onto a machine nothing holds the service manager of")
|
||||
}
|
||||
for _, want := range []string{catalogue.ServiceManagerSeat, "systemd", "ADR 0207"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(assigned, "sshd") {
|
||||
t.Fatalf("a refused assignment was kept: %v", assigned)
|
||||
}
|
||||
|
||||
// The holders depend on each other, so neither goes on alone — and both go on in one act.
|
||||
if _, err := assign(ctx, open, "laptop", "systemd"); err == nil {
|
||||
t.Fatal("systemd went on alone though its package needs a package manager")
|
||||
}
|
||||
if said, err := assign(ctx, open, "laptop", "systemd", "pacman"); err != nil {
|
||||
t.Fatalf("the two holders assigned together were refused: %v\n%s", err, said)
|
||||
}
|
||||
if said, err := assign(ctx, open, "laptop", "sshd"); err != nil {
|
||||
t.Fatalf("sshd beside its holder was refused: %v\n%s", err, said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheControllerSeatsAssignTakesSeveralModulesAsOneAct(t *testing.T) {
|
||||
argv, err := argvFor("assign", map[string]any{"node": "laptop", "module": "systemd, pacman"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(argv, " ") != "assign laptop systemd pacman" {
|
||||
t.Errorf("the seat's assign became %v", argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheLastHolderUnderItsDependentsIsRefused(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, serviceManagerHolder())
|
||||
register(t, open, packageManagerHolder())
|
||||
register(t, open, aDaemon())
|
||||
if _, err := assign(ctx, open, "laptop", "systemd", "pacman", "sshd"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := unassign(ctx, open, "laptop", "systemd")
|
||||
if err == nil {
|
||||
t.Fatal("the service manager came off a machine still running services")
|
||||
}
|
||||
for _, want := range []string{catalogue.ServiceManagerSeat, "sshd", "pacman"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
assigned, _ := open.inventory.Assigned(ctx, "laptop")
|
||||
if !contains(assigned, "systemd") {
|
||||
t.Fatalf("a refused unassignment took the module off anyway: %v", assigned)
|
||||
}
|
||||
if _, err := unassign(ctx, open, "laptop", "sshd"); err != nil {
|
||||
t.Fatalf("a dependent could not come off: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsAnUnheldDependencyWithoutRefusingTheMachine(t *testing.T) {
|
||||
// The mesh as it ran before the switch (novox/hq ADR 0207 §4).
|
||||
defer catalogue.EnforcingSeatDependencies(false)()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, serviceManagerHolder())
|
||||
register(t, open, aDaemon())
|
||||
// Assigned straight into the store: a machine whose modules predate the rule, which is every
|
||||
// machine on the day it ships.
|
||||
if _, err := open.inventory.Assign(ctx, "laptop", "sshd"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, refused := asked.refused["laptop"]; refused {
|
||||
t.Fatalf("an unmet dependency refused the machine before the switch: %s", asked.refused["laptop"])
|
||||
}
|
||||
if asked.well() {
|
||||
t.Error("a mesh with an unheld dependency reads as all well")
|
||||
}
|
||||
got := printed(t, func() error { return printStatus(asked) })
|
||||
for _, want := range []string{"unheld", "laptop", "sshd", catalogue.ServiceManagerSeat, "systemd"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("status does not say %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Unheld []catalogue.Unheld `json:"unheld"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Unheld) != 1 || doc.Unheld[0].Module != "sshd" || doc.Unheld[0].Seat != catalogue.ServiceManagerSeat {
|
||||
t.Errorf("the document's unheld is %+v", doc.Unheld)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
if err := need("node", "module"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{verb, str("node"), str("module")}, nil
|
||||
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
||||
// the seats that apply resources depend on each other and go on together.
|
||||
return append([]string{verb, str("node")}, splitModules(str("module"))...), nil
|
||||
case "pin":
|
||||
if err := need("node", "provision", "from", "module"); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -282,6 +282,22 @@ func printStatus(asked answers) error {
|
||||
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
||||
}
|
||||
|
||||
if len(asked.unheld) > 0 {
|
||||
// **Reported, and not refused yet** (novox/hq ADR 0207 §4). Each machine still resolves and
|
||||
// is sent what it would be; this says which of its modules depend on a seat nothing there
|
||||
// holds, until every machine has its holders and the switch makes it a refusal.
|
||||
fmt.Printf("%d module dependenc(ies) on a seat nothing on the machine holds (unheld, ADR 0207):\n",
|
||||
len(asked.unheld))
|
||||
for _, u := range asked.unheld {
|
||||
holders := "no module in the catalogue claims it yet"
|
||||
if len(u.Holders) > 0 {
|
||||
holders = "could be held by " + strings.Join(u.Holders, ", ")
|
||||
}
|
||||
fmt.Printf(" %-12s %-24s %-24s %s\n", u.Node, u.Module, u.Seat, holders)
|
||||
}
|
||||
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\n\n")
|
||||
}
|
||||
|
||||
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
||||
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||
@@ -386,6 +402,20 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
}
|
||||
// And which machines run a module whose resources a seat nothing there holds applies (novox/hq
|
||||
// ADR 0207). Each machine resolved again rather than threaded through whoResolves, whose answer
|
||||
// the private network is built from and should say nothing else; a machine that does not
|
||||
// resolve is already in refused, and is passed over here.
|
||||
for _, n := range out.nodes {
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
if unresolvable(err) {
|
||||
continue
|
||||
}
|
||||
return answers{}, err
|
||||
}
|
||||
out.unheld = append(out.unheld, plan.Unheld...)
|
||||
}
|
||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
@@ -496,7 +526,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
||||
func (a answers) well() bool {
|
||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||
len(a.filtered) == 0
|
||||
len(a.filtered) == 0 && len(a.unheld) == 0
|
||||
}
|
||||
|
||||
// hostSplit is which machines report which host version, for every version more than one machine
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// An act says what it changed about the node it acted on, and nothing about the rest of the mesh
|
||||
// (novox/hq ADR 0207): after the seat dependencies shipped, every `push <node>` and `assign` printed
|
||||
// every node's unmet dependencies, a hundred lines around the one about the module just assigned.
|
||||
|
||||
func aContainer(name string) catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: name, Version: "1",
|
||||
Resources: []map[string]any{{"id": name, "type": "container", "image": name}}}
|
||||
}
|
||||
|
||||
func TestAnAssignmentSaysOnlyWhatItChangedOnItsOwnNode(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, aContainer("web"))
|
||||
register(t, open, aContainer("db"))
|
||||
// anchor already lacks a runtime for db: true, and not this act's to say.
|
||||
if _, err := open.inventory.Assign(ctx, "anchor", "db"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := open.inventory.Assign(ctx, "laptop", "db"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := assign(ctx, open, "laptop", "web")
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, said)
|
||||
}
|
||||
if !strings.Contains(said, "web on laptop depends on "+catalogue.ContainerRuntimeSeat) {
|
||||
t.Errorf("the assignment does not say what the module it assigned depends on:\n%s", said)
|
||||
}
|
||||
for _, not := range []string{"db on laptop", "db on anchor", "anchor:"} {
|
||||
if strings.Contains(said, not) {
|
||||
t.Errorf("the assignment says %q, which it did not change:\n%s", not, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssignmentThatMeetsADependencySaysSo(t *testing.T) {
|
||||
shelf := map[string]catalogue.Manifest{
|
||||
"web": aContainer("web"),
|
||||
"docker": {Module: "docker", Claims: []catalogue.Claim{{Name: catalogue.ContainerRuntimeSeat}},
|
||||
Resources: []map[string]any{{"id": "d", "type": "container", "image": "dind"}}},
|
||||
}
|
||||
lines := unheldChange(shelf, "laptop", []string{"web"}, []string{"web", "docker"})
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "web on laptop now has "+catalogue.ContainerRuntimeSeat+" held") {
|
||||
t.Errorf("meeting a dependency said %v", lines)
|
||||
}
|
||||
// Taking the dependent off says nothing: the dependency went with its module.
|
||||
if lines := unheldChange(shelf, "laptop", []string{"web"}, nil); len(lines) != 0 {
|
||||
t.Errorf("unassigning the dependent said %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPushSaysANamedNodesDependenciesAndCountsTheRest(t *testing.T) {
|
||||
unheld := map[string][]catalogue.Unheld{
|
||||
"anchor": {{Node: "anchor", Module: "db", Seat: catalogue.ContainerRuntimeSeat}},
|
||||
"laptop": {{Node: "laptop", Module: "web", Seat: catalogue.ContainerRuntimeSeat},
|
||||
{Node: "laptop", Module: "sshd", Seat: catalogue.ServiceManagerSeat}},
|
||||
}
|
||||
var named bytes.Buffer
|
||||
reportUnheldPushed(&named, true, []string{"laptop"}, unheld)
|
||||
got := named.String()
|
||||
if !strings.Contains(got, "laptop has 2 unmet") || !strings.Contains(got, "web on laptop") ||
|
||||
!strings.Contains(got, "sshd on laptop") || strings.Contains(got, "anchor") {
|
||||
t.Errorf("a named push said:\n%s", got)
|
||||
}
|
||||
var all bytes.Buffer
|
||||
reportUnheldPushed(&all, false, []string{"anchor", "laptop", "quiet"}, unheld)
|
||||
want := "anchor: 1 unmet seat dependenc(ies) — see `status`\nlaptop: 2 unmet seat dependenc(ies) — see `status`\n"
|
||||
if all.String() != want {
|
||||
t.Errorf("a push to every node said:\n%s\nwant\n%s", all.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheServingControllerLogsEachChange(t *testing.T) {
|
||||
read := func(f func()) string {
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
f()
|
||||
_ = w.Close()
|
||||
os.Stderr = old
|
||||
var b bytes.Buffer
|
||||
_, _ = b.ReadFrom(r)
|
||||
return b.String()
|
||||
}
|
||||
u := []catalogue.Unheld{{Node: "n1", Module: "web", Seat: catalogue.ContainerRuntimeSeat}}
|
||||
if got := read(func() { logUnheld("n1", u) }); got != "" {
|
||||
t.Errorf("a command logged:\n%s", got)
|
||||
}
|
||||
logUnheldChanges = true
|
||||
defer func() { logUnheldChanges = false }()
|
||||
if got := read(func() { logUnheld("n1", u) }); !strings.Contains(got, "web on n1") {
|
||||
t.Errorf("the serving controller did not log a change:\n%s", got)
|
||||
}
|
||||
if got := read(func() { logUnheld("n1", u) }); got != "" {
|
||||
t.Errorf("an unchanged report was logged again:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0210 §3: a contribution is a dependency on the seat that receives it.
|
||||
|
||||
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
env := mod("theme", nil, nil, nil)
|
||||
env.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
path := mod("toolchain", nil, nil, nil)
|
||||
path.Environment = &Environment{Path: []PathEntry{{Entry: "/opt/x/bin"}}}
|
||||
shell := mod("prompt", nil, nil, nil)
|
||||
shell.Shell = []ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
|
||||
session := mod("wallpaper", nil, nil, nil)
|
||||
session.Shell = []ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
|
||||
resources := mod("bar", nil, nil, nil)
|
||||
resources.Shell = []ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
|
||||
empty := mod("nothing", nil, nil, nil)
|
||||
empty.Environment = &Environment{}
|
||||
|
||||
cases := map[string]struct {
|
||||
m Manifest
|
||||
want []string
|
||||
}{
|
||||
"a variable": {env, []string{EnvironmentSeat}},
|
||||
"a path entry": {path, []string{EnvironmentSeat}},
|
||||
"shell code": {shell, []string{LoginShellSeat}},
|
||||
"the session's start": {session, []string{DisplayServerSeat}},
|
||||
"the session's X resources": {resources, []string{DisplayServerSeat}},
|
||||
"an empty environment": {empty, nil},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := DependsOn(c.m)
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s depends on %v, want %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) {
|
||||
holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})
|
||||
contributor := mod("theme", nil, nil, nil)
|
||||
contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
catalogue := map[string]Manifest{"node-env": holder, "theme": contributor}
|
||||
|
||||
if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil {
|
||||
t.Fatalf("a contributor beside the holder is refused: %v", err)
|
||||
}
|
||||
_, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"})
|
||||
if err == nil {
|
||||
t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere")
|
||||
}
|
||||
if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") {
|
||||
t.Errorf("the refusal names neither the seat nor its holder: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil {
|
||||
t.Errorf("the contributor and the holder assigned together are refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
|
||||
// The display server's module contributes nothing to its own seat today, but the zsh module's
|
||||
// environment contributions do go to another seat: a claim meets only the seat it names.
|
||||
zsh := mod("zsh", nil, nil, nil, Claim{Name: LoginShellSeat})
|
||||
zsh.Shell = []ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
|
||||
zsh.Environment = &Environment{Variables: map[string]string{"EDITOR": "vim"}}
|
||||
catalogue := map[string]Manifest{"zsh": zsh,
|
||||
"node-env": mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})}
|
||||
unheld := UnheldDependencies(catalogue, "laptop", []Manifest{zsh}, nil)
|
||||
if len(unheld) != 1 || unheld[0].Seat != EnvironmentSeat {
|
||||
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *testing.T) {
|
||||
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
|
||||
res("package", "pacman-contrib"))
|
||||
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
|
||||
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar, "other": other}
|
||||
|
||||
err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"})
|
||||
if err == nil || !strings.Contains(err.Error(), "pacman-contrib") || !strings.Contains(err.Error(), "bar") {
|
||||
t.Fatalf("the second owner of a package was not refused by name: %v", err)
|
||||
}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"other"}); err != nil {
|
||||
t.Errorf("a module declaring nothing shared is refused: %v", err)
|
||||
}
|
||||
// A collision already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman", "bar"}, []string{"other"}); err != nil {
|
||||
t.Errorf("an unrelated assignment is refused for a collision already there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||
for _, moment := range powerMoments {
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||
}
|
||||
if p := m.shellProblems(); len(p) != 0 {
|
||||
t.Errorf("code for %s is refused: %v", moment, p)
|
||||
}
|
||||
}
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
|
||||
if p := m.shellProblems(); len(p) == 0 {
|
||||
t.Error("code for a moment that does not exist was accepted")
|
||||
}
|
||||
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
|
||||
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
const (
|
||||
EnvironmentSeat = "node-environment"
|
||||
LoginShellSeat = "node-login-shell"
|
||||
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
|
||||
PowerSeat = "node-power"
|
||||
)
|
||||
|
||||
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
||||
@@ -68,8 +70,34 @@ type ShellCode struct {
|
||||
var (
|
||||
knownShells = []string{"zsh", "bash", "fish"}
|
||||
knownSlots = []string{"first", "normal", "last"}
|
||||
// sessionFiles are the two files of the graphical session's start that read no directory, so a
|
||||
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
|
||||
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
|
||||
// display server's holder, as a shell's slots are placed by the login shell's.
|
||||
sessionFiles = []string{"xinitrc", "xresources"}
|
||||
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
|
||||
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
|
||||
powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
|
||||
)
|
||||
|
||||
// contributionTargets is every name a contribution's `for` may take.
|
||||
func contributionTargets() []string {
|
||||
out := append(append([]string(nil), knownShells...), sessionFiles...)
|
||||
return append(out, powerMoments...)
|
||||
}
|
||||
|
||||
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
||||
// ADR 0208 §4).
|
||||
func placerOf(target string) string {
|
||||
if oneOf(sessionFiles, target) {
|
||||
return DisplayServerSeat
|
||||
}
|
||||
if oneOf(powerMoments, target) {
|
||||
return PowerSeat
|
||||
}
|
||||
return LoginShellSeat
|
||||
}
|
||||
|
||||
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
|
||||
const (
|
||||
EnvironmentPOSIX = "posix"
|
||||
@@ -170,10 +198,11 @@ func literalProblem(v string) string {
|
||||
func (m Manifest) shellProblems() []string {
|
||||
var problems []string
|
||||
for i, c := range m.Shell {
|
||||
if !oneOf(knownShells, c.For) {
|
||||
if !oneOf(contributionTargets(), c.For) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's shell code %d is for %q; the shells are %s", m.Module, i+1, c.For,
|
||||
strings.Join(knownShells, ", ")))
|
||||
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
|
||||
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
|
||||
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
|
||||
}
|
||||
if !oneOf(knownSlots, c.Slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
@@ -237,20 +266,43 @@ func placeholderProblems(m Manifest, r map[string]any) []string {
|
||||
"written by that seat's holder alone (novox/hq ADR 0203)",
|
||||
m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat))
|
||||
}
|
||||
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
||||
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
||||
// one placing the other's would be a second writer of a file there is one of.
|
||||
refusedFor := map[string]bool{}
|
||||
for _, c := range code {
|
||||
shell, slot, two := strings.Cut(c[1], ":")
|
||||
if !two || !oneOf(knownShells, shell) || !oneOf(knownSlots, slot) {
|
||||
target, slot, two := strings.Cut(c[1], ":")
|
||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
||||
"%s and the slot one of %s", m.Module, r["id"], c[0],
|
||||
strings.Join(knownShells, ", "), strings.Join(knownSlots, ", ")))
|
||||
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
|
||||
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
||||
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
|
||||
continue
|
||||
}
|
||||
seat := placerOf(target)
|
||||
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
||||
continue
|
||||
}
|
||||
refusedFor[seat] = true
|
||||
if seat == PowerSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
|
||||
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
|
||||
m.Module, r["id"], c[0], m.Module, seat))
|
||||
continue
|
||||
}
|
||||
if seat == DisplayServerSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
||||
"the display server's holder alone (novox/hq ADR 0208)",
|
||||
m.Module, r["id"], c[0], m.Module, seat, target))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(code) > 0 && !m.ClaimsSeat(LoginShellSeat) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
||||
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
||||
m.Module, r["id"], code[0][0], m.Module, LoginShellSeat))
|
||||
m.Module, r["id"], c[0], m.Module, seat))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package catalogue
|
||||
|
||||
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
||||
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
|
||||
// a role rather than each inventing one — and a machine running two of one role is refused at
|
||||
// assignment instead of found by two bars on one screen.
|
||||
const (
|
||||
LoginManagerSeat = "node-login-manager"
|
||||
DisplayServerSeat = "node-display-server"
|
||||
DisplaySessionSeat = "node-display-session"
|
||||
TerminalEmulatorSeat = "node-terminal-emulator"
|
||||
LauncherSeat = "node-launcher"
|
||||
NotifierSeat = "node-notifier"
|
||||
LockScreenSeat = "node-lock-screen"
|
||||
ClipboardSeat = "node-clipboard"
|
||||
BarSeat = "node-bar"
|
||||
CompositorSeat = "node-compositor"
|
||||
SecretServiceSeat = "node-secret-service"
|
||||
)
|
||||
|
||||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||||
// research 026/05 starts it with. Three have none yet: the bar, the compositor and the secret
|
||||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||||
func graphicalSessionSeats() []Seat {
|
||||
const decided = "novox/hq ADR 0208"
|
||||
return []Seat{
|
||||
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||||
"one the operator account starts by default.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}},
|
||||
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||||
"where it is placed; and the layout profile in force, if one matches.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||||
"identities: list them, save the current arrangement under a name, or apply one.",
|
||||
Input: withEnum(schema(map[string]string{
|
||||
"action": "list, save or apply",
|
||||
"name": "the profile to save or apply (save and apply only)",
|
||||
}, []string{"action"}), "action", "list", "save", "apply")},
|
||||
}},
|
||||
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||||
"it is visible or focused.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||||
"whether it has focus; narrowed to one workspace when named.",
|
||||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||||
}},
|
||||
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||||
"or the login shell, in a directory or the account's home.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "what to run in it (optional; the login shell when absent)",
|
||||
"directory": "where it starts (optional; the account's home when absent)",
|
||||
}, nil)},
|
||||
}},
|
||||
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||||
"properties": map[string]any{
|
||||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the lines to choose between, in order"},
|
||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||
}}},
|
||||
}},
|
||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "send", Description: "Show the operator a notification.",
|
||||
Input: withEnum(schema(map[string]string{
|
||||
"title": "the notification's summary",
|
||||
"body": "its text (optional)",
|
||||
"urgency": "low, normal (the default) or critical",
|
||||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||
}},
|
||||
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "lock", Description: "Lock the operator's session now.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}},
|
||||
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||||
}},
|
||||
{Name: BarSeat, Scope: ScopeNode, Decision: decided},
|
||||
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
||||
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
||||
}
|
||||
}
|
||||
|
||||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||||
// the choices by the schema rather than by a refusal.
|
||||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||||
props := s["properties"].(map[string]any)
|
||||
p := props[property].(map[string]any)
|
||||
p["enum"] = values
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats.
|
||||
|
||||
// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with.
|
||||
func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||
want := map[string][]string{
|
||||
LoginManagerSeat: {"sessions"},
|
||||
DisplayServerSeat: {"displays", "layout"},
|
||||
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||
TerminalEmulatorSeat: {"open"},
|
||||
LauncherSeat: {"menu"},
|
||||
NotifierSeat: {"send", "history"},
|
||||
LockScreenSeat: {"lock"},
|
||||
ClipboardSeat: {"history", "copy"},
|
||||
BarSeat: nil,
|
||||
CompositorSeat: nil,
|
||||
SecretServiceSeat: nil,
|
||||
}
|
||||
for name, verbs := range want {
|
||||
s, ok := SeatNamed(name)
|
||||
if !ok {
|
||||
t.Errorf("%s is not in the mesh's set", name)
|
||||
continue
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" {
|
||||
t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision)
|
||||
}
|
||||
var got []string
|
||||
for _, v := range s.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Description == "" || v.Input["type"] != "object" {
|
||||
t.Errorf("%s.%s has no description or no object schema", name, v.Name)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, verbs) {
|
||||
t.Errorf("%s serves %v, want %v", name, got, verbs)
|
||||
}
|
||||
}
|
||||
// The launcher's menu takes a list, and the layout verb says its actions.
|
||||
menu, _ := SeatNamed(LauncherSeat)
|
||||
choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any)
|
||||
if choices["type"] != "array" {
|
||||
t.Errorf("menu's choices are %v, not a list", choices["type"])
|
||||
}
|
||||
display, _ := SeatNamed(DisplayServerSeat)
|
||||
action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any)
|
||||
if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) {
|
||||
t.Errorf("layout's actions are %v", action["enum"])
|
||||
}
|
||||
// And they survive the store's JSON, which is where the live set comes from.
|
||||
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// §2: a module may claim one of them, and may not declare it as its own.
|
||||
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
||||
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
||||
t.Fatalf("a module declared node-display-server as its own: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func displayServer(name, display string, claims ...string) Manifest {
|
||||
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
|
||||
for _, c := range claims {
|
||||
m.Claims = append(m.Claims, Claim{Name: c})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func windowManager() Manifest {
|
||||
return Manifest{Module: "i3", Requires: []string{"x11-display"}}
|
||||
}
|
||||
|
||||
// §3: a display is resolved on the requiring module's own node.
|
||||
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
|
||||
cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat))
|
||||
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("i3 beside xorg did not resolve: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
|
||||
t.Errorf("resolved %v", names(got))
|
||||
}
|
||||
}
|
||||
|
||||
// §3: never answered by installing a provider, and never by another machine's.
|
||||
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
|
||||
cat := shelf(windowManager(),
|
||||
displayServer("xorg", "x11-display", DisplayServerSeat),
|
||||
displayServer("xwayland", "x11-display"))
|
||||
// Another machine runs xorg and says so to the world; it does not count.
|
||||
world := World{Offered: map[string][]Provider{
|
||||
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
|
||||
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
|
||||
if err == nil {
|
||||
t.Fatal("i3 resolved on a machine with no display of its own")
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
|
||||
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
// With a single provider in the catalogue too: one candidate is still not a choice to make
|
||||
// for somebody, unlike a node-scoped provision without the machine's reach.
|
||||
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)),
|
||||
[]string{"i3"}, workstation(), World{})
|
||||
if err == nil {
|
||||
t.Fatal("xorg was pulled in for i3")
|
||||
}
|
||||
// Not in the first pass, whose refusals take the machine off the network.
|
||||
if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil {
|
||||
t.Errorf("the first pass refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
||||
for _, c := range []struct{ provides, want string }{
|
||||
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
||||
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
||||
}
|
||||
}
|
||||
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Provides[0].MachineReach() {
|
||||
t.Fatal("the reach was not read")
|
||||
}
|
||||
back, _ := json.Marshal(m.Provides[0])
|
||||
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
||||
t.Errorf("written back as %s", back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
|
||||
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
|
||||
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
|
||||
t.Fatalf("a provision with and without the machine's reach gave %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
||||
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
||||
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
|
||||
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
||||
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
||||
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
||||
"content": "${shell:xresources:normal}"},
|
||||
}}
|
||||
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
||||
theme := Manifest{Module: "theme", Shell: []ShellCode{
|
||||
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
||||
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
||||
}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]any{}
|
||||
for _, res := range out {
|
||||
by[res["id"].(string)] = res["content"]
|
||||
}
|
||||
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
||||
t.Errorf("the .xinitrc is %q", got)
|
||||
}
|
||||
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
||||
t.Errorf("the .Xresources is %q", got)
|
||||
}
|
||||
|
||||
// The contributions parse; the placeholders parse only in the holder.
|
||||
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
||||
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
||||
t.Fatalf("a session contribution was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ claims, content, want string }{
|
||||
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
||||
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
||||
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
||||
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
|
||||
c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
||||
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -147,8 +147,17 @@ type Offer struct {
|
||||
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
||||
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
// Reach is ReachMachine for a provision usable only on the provider's own machine — a display
|
||||
// (novox/hq ADR 0208 §3). Node scope already keeps a provision off other machines; what this adds
|
||||
// is that a requirement for it is never answered by installing a provider: the display server is
|
||||
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
|
||||
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
|
||||
Reach string `json:"reach,omitempty"`
|
||||
}
|
||||
|
||||
// MachineReach is whether a provision is usable only on its provider's own machine.
|
||||
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
|
||||
|
||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
||||
type OfferCredential struct {
|
||||
Own string `json:"own"`
|
||||
@@ -196,27 +205,29 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
Reach string `json:"reach,omitempty"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err)
|
||||
}
|
||||
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
||||
o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||
// went through the mesh comes out looking like the one that went in.
|
||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
if o.Scope == "" && o.Credential == nil {
|
||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" {
|
||||
return json.Marshal(o.Name)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential})
|
||||
Reach string `json:"reach,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential, o.Reach})
|
||||
}
|
||||
|
||||
// Manifest is everything a module says about itself.
|
||||
@@ -1317,6 +1328,19 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"%s provides %q at scope %q; a provision is %q or %q",
|
||||
m.Module, p, s, ScopeNode, ScopeMesh))
|
||||
}
|
||||
switch {
|
||||
case offer.Reach == "":
|
||||
case offer.Reach != ReachMachine:
|
||||
// The one reach a provision has (novox/hq ADR 0208): a provision reached over the private
|
||||
// network is mesh scope, and the world reaches nothing but a name.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with reach %q; a provision's reach is %q or nothing",
|
||||
m.Module, p, offer.Reach, ReachMachine))
|
||||
case offer.At() != ScopeNode:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+
|
||||
"machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At()))
|
||||
}
|
||||
if p == m.Module {
|
||||
// Harmless and worth saying: a module always provides its own name, so writing it
|
||||
// suggests the author expected it not to.
|
||||
|
||||
@@ -147,6 +147,10 @@ type Resolution struct {
|
||||
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
||||
// different case — that set is incoherent and is refused (see checkCapabilities).
|
||||
Unhostable []Unhostable
|
||||
// Unheld is every dependency of this node's modules on a seat nothing here holds (novox/hq ADR
|
||||
// 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a
|
||||
// holder still converges and `status` says what it is short of.
|
||||
Unheld []Unheld
|
||||
}
|
||||
|
||||
// Unhostable is one directly-assigned module the machine cannot run.
|
||||
@@ -229,6 +233,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
local[o.Name] = true
|
||||
}
|
||||
}
|
||||
// Which names are usable only on their provider's own machine (novox/hq ADR 0208 §3). Also a
|
||||
// property of the name: a display one provider says is the machine's and another says is not
|
||||
// would be pulled in for one consumer and refused for the next.
|
||||
machineReach := map[string]bool{}
|
||||
plainLocal := map[string]bool{}
|
||||
for _, m := range catalogue {
|
||||
for _, o := range m.Provides {
|
||||
if o.MachineReach() {
|
||||
machineReach[o.Name] = true
|
||||
} else if o.At() == ScopeNode {
|
||||
plainLocal[o.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for want := range machineReach {
|
||||
if plainLocal[want] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"the catalogue disagrees about %q: some modules provide it with the machine's reach and "+
|
||||
"others without, so a requirement for it would be met differently by each", want))
|
||||
}
|
||||
}
|
||||
for want := range brokered {
|
||||
if local[want] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
@@ -495,6 +520,23 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
continue
|
||||
}
|
||||
|
||||
// Usable only on its provider's own machine, and not here: refused, never answered by
|
||||
// installing a provider (novox/hq ADR 0208 §3). The display server is the machine's own role,
|
||||
// gated by its graphical session; one pulled in for a window manager is the misassignment
|
||||
// research 026 found. Another node's provider never counts — node scope is never brokered.
|
||||
if machineReach[want] && !isModule(catalogue, want) {
|
||||
reported[want] = true
|
||||
if world.Unchecked {
|
||||
// The first pass's refusals take a machine off the network; the second says it.
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%q is wanted by %s and is usable only on the machine that provides it, and nothing "+
|
||||
"assigned to %s does — %s", want, because[want], node.Name,
|
||||
machineReachRemedy(catalogue, want, node.Name)))
|
||||
continue
|
||||
}
|
||||
|
||||
candidates := offers[want]
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
@@ -628,6 +670,22 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
problems = append(problems, checkResources(resolution.Modules)...)
|
||||
resolution.Claims = claims
|
||||
|
||||
// Judged over the closure — what this node will actually run — so a holder pulled in by a
|
||||
// requirement counts, and the holders' mutual dependence resolves (novox/hq ADR 0207 §3).
|
||||
// Reported until the switch; refused after it, though never in the first pass, whose refusals
|
||||
// make a machine vanish from the network rather than report anything.
|
||||
resolution.Unheld = UnheldDependencies(catalogue, node.Name, resolution.Modules, nil)
|
||||
// Only a dependency some module in the catalogue could meet is refused: one with no possible
|
||||
// holder has no remedy to name, and stays a report in `status` (novox/hq ADR 0207 §4, read with
|
||||
// the assign rule above it).
|
||||
if enforceSeatDependencies && !world.Unchecked {
|
||||
for _, u := range resolution.Unheld {
|
||||
if len(u.Holders) > 0 {
|
||||
problems = append(problems, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return Resolution{}, &Refusal{Problems: problems}
|
||||
@@ -804,6 +862,28 @@ func checkResources(modules []Manifest) []string {
|
||||
// does not exist is the manifest's own problem, refused where it was made.
|
||||
dirs := dirsFor(m, Rendering{})
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "user" {
|
||||
// **An account is shared; what it is set to is not.** Several modules may need one
|
||||
// login: the shell's module sets its shell, the container runtime's puts it in the
|
||||
// `docker` group. The host only ever adds groups — it never takes the account out of
|
||||
// one, not even when the resource that named it is undeclared — so groups from
|
||||
// several modules cannot contradict each other and are not owned. A shell or a home
|
||||
// is one value, and two modules setting it would each be undone by the other's
|
||||
// apply: each stays one module's per node, and two are refused naming both.
|
||||
name, _ := r["name"].(string)
|
||||
for _, field := range []string{"shell", "home"} {
|
||||
if v, ok := r[field].(string); !ok || v == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
key := "user " + field + " " + name
|
||||
if other, taken := owner[key]; taken && other != m.Module {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s and %s both set the %s of the user %q", other, m.Module, field, name))
|
||||
}
|
||||
owner[key] = m.Module
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, field := range []string{"path", "unit", "name", "package"} {
|
||||
value, ok := r[field].(string)
|
||||
if !ok || value == "" {
|
||||
@@ -1019,3 +1099,38 @@ func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed
|
||||
}
|
||||
return needs
|
||||
}
|
||||
|
||||
// machineReachRemedy names what would meet a requirement with the machine's reach: every module in
|
||||
// the catalogue that provides it, each with the node seats it holds — for a display, the holders of
|
||||
// node-display-server (novox/hq ADR 0208 §3), named by the seat because that is the role being
|
||||
// asked for, without this code knowing which seat any provision belongs to.
|
||||
func machineReachRemedy(catalogue map[string]Manifest, want, node string) string {
|
||||
var named []string
|
||||
for _, name := range sortedKeys(catalogue) {
|
||||
m := catalogue[name]
|
||||
provides := false
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == want {
|
||||
provides = true
|
||||
}
|
||||
}
|
||||
if !provides {
|
||||
continue
|
||||
}
|
||||
var held []string
|
||||
for _, c := range m.Claims {
|
||||
if c.At() == ScopeNode {
|
||||
held = append(held, c.Name)
|
||||
}
|
||||
}
|
||||
if len(held) > 0 {
|
||||
named = append(named, fmt.Sprintf("%s (holds %s)", name, strings.Join(held, ", ")))
|
||||
} else {
|
||||
named = append(named, name)
|
||||
}
|
||||
}
|
||||
if len(named) == 0 {
|
||||
return "and nothing in the catalogue provides it"
|
||||
}
|
||||
return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; "))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), and on the
|
||||
// seats it contributes to (novox/hq ADR 0210).
|
||||
//
|
||||
// Some of what a module declares is applied through software on the machine that is itself a
|
||||
// module: a service through the service manager, a package through the package manager, a container
|
||||
// through the container runtime. A *capability* only says that software is installed; it does not
|
||||
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
||||
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
||||
// field would pass — and is met when some module assigned to the same node holds the seat.
|
||||
|
||||
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
||||
// the seed and the messages all turn on these strings.
|
||||
const (
|
||||
ServiceManagerSeat = "node-service-manager"
|
||||
PackageManagerSeat = "node-package-manager"
|
||||
ContainerRuntimeSeat = "node-container-runtime"
|
||||
)
|
||||
|
||||
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
||||
// names them and no more. A process is supervised by the host itself, a file, a directory, an
|
||||
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
|
||||
// without a role in between — adding one here is a decision, not a refinement.
|
||||
var appliedThrough = map[string]string{
|
||||
"service": ServiceManagerSeat,
|
||||
"package": PackageManagerSeat,
|
||||
"container": ContainerRuntimeSeat,
|
||||
}
|
||||
|
||||
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
|
||||
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
|
||||
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
|
||||
// which is when the three holders are assigned to every node: switching it before then would stop
|
||||
// every machine lacking one from being sent anything at all.
|
||||
//
|
||||
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
|
||||
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
|
||||
//
|
||||
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
||||
var enforceSeatDependencies = true
|
||||
|
||||
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
|
||||
// packages that hold the behaviour from before the switch; nothing else calls it.
|
||||
func EnforcingSeatDependencies(on bool) (restore func()) {
|
||||
was := enforceSeatDependencies
|
||||
enforceSeatDependencies = on
|
||||
return func() { enforceSeatDependencies = was }
|
||||
}
|
||||
|
||||
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
||||
// the host and the private network. Registered as modules so they can be assigned, but what they
|
||||
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
||||
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
|
||||
// bundle the host applies itself, and never pass through a resolution here.
|
||||
//
|
||||
// The private network's module is overlay.Name, written out because the overlay package composes
|
||||
// on top of this one; its resources are computed, which exempts it by the rule below as well.
|
||||
var foundationModules = map[string]bool{
|
||||
"mesh-host": true,
|
||||
"mesh-wireguard": true,
|
||||
}
|
||||
|
||||
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
|
||||
// module whose resources are computed is the mesh's by construction — the private network's peer
|
||||
// list and its tools are the controller's, written per node — so it counts whatever its name.
|
||||
func isFoundation(m Manifest) bool {
|
||||
return foundationModules[m.Module] || m.Computed != ""
|
||||
}
|
||||
|
||||
// DependsOn is every seat a module needs held on its node, derived from the resource types it
|
||||
// declares itself (novox/hq ADR 0207 §2), sorted.
|
||||
//
|
||||
// **The module's own `resources` only.** What the controller composes around a module — its
|
||||
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
|
||||
// module is assigned, and depending on it would make the module answer for the mesh's choices.
|
||||
func DependsOn(m Manifest) []string {
|
||||
if isFoundation(m) {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
|
||||
seen[seat] = true
|
||||
}
|
||||
}
|
||||
for _, seat := range contributedTo(m) {
|
||||
seen[seat] = true
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
|
||||
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
|
||||
// resource is on the seat that applies it. The environment goes to node-environment's holder
|
||||
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
|
||||
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
|
||||
func contributedTo(m Manifest) []string {
|
||||
var out []string
|
||||
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
|
||||
out = append(out, EnvironmentSeat)
|
||||
}
|
||||
for _, c := range m.Shell {
|
||||
out = append(out, placerOf(c.For))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
|
||||
// (ADR 0122), so a rename leaves the dependency met.
|
||||
func claimsSeat(m Manifest, seat string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if c.At() != ScopeNode {
|
||||
continue
|
||||
}
|
||||
name := c.Name
|
||||
if s, known := SeatNamed(name); known {
|
||||
name = s.Name
|
||||
}
|
||||
if name == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
|
||||
// refusal names as the remedy.
|
||||
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
|
||||
var out []string
|
||||
for name, m := range catalogue {
|
||||
if claimsSeat(m, seat) {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Unheld is one dependency of one module on a node that nothing on that node holds.
|
||||
type Unheld struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Seat string `json:"seat"`
|
||||
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
|
||||
Holders []string `json:"holders"`
|
||||
}
|
||||
|
||||
// String is the line a refusal and a report both say, so the two never drift.
|
||||
func (u Unheld) String() string {
|
||||
remedy := "and no module in the catalogue claims it yet"
|
||||
if len(u.Holders) > 0 {
|
||||
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
|
||||
u.Module, u.Node, u.Seat, u.Node, remedy)
|
||||
}
|
||||
|
||||
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
|
||||
// leaves unmet (novox/hq ADR 0207 §3).
|
||||
//
|
||||
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
|
||||
// the service manager's own package needs the package manager, and the package manager's timer
|
||||
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
|
||||
// two assigned together meet each other. A module holding a seat it depends on meets its own
|
||||
// dependency. `judged` nil judges every module of the set.
|
||||
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
|
||||
held := map[string]bool{}
|
||||
for _, m := range set {
|
||||
for _, seat := range nodeSeatsClaimed(m) {
|
||||
held[seat] = true
|
||||
}
|
||||
}
|
||||
var out []Unheld
|
||||
for _, m := range set {
|
||||
if judged != nil && !judged[m.Module] {
|
||||
continue
|
||||
}
|
||||
for _, seat := range DependsOn(m) {
|
||||
if held[seat] {
|
||||
continue
|
||||
}
|
||||
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
|
||||
Holders: PossibleHolders(catalogue, seat)})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Module != out[j].Module {
|
||||
return out[i].Module < out[j].Module
|
||||
}
|
||||
return out[i].Seat < out[j].Seat
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
|
||||
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
|
||||
func nodeSeatsClaimed(m Manifest) []string {
|
||||
var out []string
|
||||
for _, c := range m.Claims {
|
||||
if c.At() != ScopeNode {
|
||||
continue
|
||||
}
|
||||
name := c.Name
|
||||
if s, known := SeatNamed(name); known {
|
||||
name = s.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
||||
// know contributes nothing, as it does to a resolution.
|
||||
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
||||
var out []Manifest
|
||||
seen := map[string]bool{}
|
||||
for _, n := range names {
|
||||
if m, known := catalogue[n]; known && !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
||||
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
||||
// included, leave unmet.
|
||||
//
|
||||
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
||||
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
||||
//
|
||||
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
||||
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
||||
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
||||
// still says it, and `status` reports it — before the switch and after it alike: there is never a
|
||||
// remedy to name for it. The first return is those lines.
|
||||
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
||||
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
||||
judged := map[string]bool{}
|
||||
for _, a := range adding {
|
||||
judged[a] = true
|
||||
}
|
||||
var refused, said []string
|
||||
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
||||
if len(u.Holders) == 0 {
|
||||
said = append(said, u.String())
|
||||
continue
|
||||
}
|
||||
refused = append(refused, u.String())
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return said, &Refusal{Problems: append(refused,
|
||||
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
||||
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
||||
// a module left there depends on it. Names the dependents, because they are what must go first —
|
||||
// or the holder's replacement come.
|
||||
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
||||
gone := map[string]bool{}
|
||||
for _, r := range removing {
|
||||
gone[r] = true
|
||||
}
|
||||
var left []string
|
||||
for _, a := range assigned {
|
||||
if !gone[a] {
|
||||
left = append(left, a)
|
||||
}
|
||||
}
|
||||
before := map[string]bool{}
|
||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
||||
before[u.Module+"\x00"+u.Seat] = true
|
||||
}
|
||||
dependents := map[string][]string{}
|
||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
||||
if before[u.Module+"\x00"+u.Seat] {
|
||||
continue // unmet already; not this removal's doing
|
||||
}
|
||||
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
||||
}
|
||||
if len(dependents) == 0 {
|
||||
return nil
|
||||
}
|
||||
seats := make([]string, 0, len(dependents))
|
||||
for s := range dependents {
|
||||
seats = append(seats, s)
|
||||
}
|
||||
sort.Strings(seats)
|
||||
var problems []string
|
||||
for _, s := range seats {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
||||
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
||||
strings.Join(dependents[s], ", ")))
|
||||
}
|
||||
return &Refusal{Problems: problems}
|
||||
}
|
||||
|
||||
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
|
||||
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
|
||||
//
|
||||
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
||||
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
|
||||
// A collision is not an order anything can complete — no further assignment makes two owners of one
|
||||
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
|
||||
// it from the mesh. Only collisions involving a module being added are refused; one already on the
|
||||
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
|
||||
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
||||
before := map[string]bool{}
|
||||
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
|
||||
before[p] = true
|
||||
}
|
||||
var problems []string
|
||||
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
|
||||
if before[p] {
|
||||
continue // on the node already; not this assignment's doing
|
||||
}
|
||||
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
||||
"depends on the owner's seat instead)")
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources.
|
||||
|
||||
func res(kind, id string) map[string]any {
|
||||
r := map[string]any{"id": id, "type": kind}
|
||||
switch kind {
|
||||
case "service":
|
||||
r["unit"] = id + ".service"
|
||||
case "package":
|
||||
r["package"] = id
|
||||
case "container":
|
||||
r["image"] = id
|
||||
case "file":
|
||||
r["path"] = "/etc/" + id
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func withResources(m Manifest, rs ...map[string]any) Manifest {
|
||||
m.Resources = rs
|
||||
return m
|
||||
}
|
||||
|
||||
// The three holders as to-be 42 names them, each declaring what it really does: systemd's own
|
||||
// package needs the package manager, pacman's timer needs the service manager, docker's package and
|
||||
// service need both.
|
||||
func coreThree() []Manifest {
|
||||
return []Manifest{
|
||||
withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")),
|
||||
withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")),
|
||||
withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}),
|
||||
res("package", "docker"), res("service", "docker")),
|
||||
}
|
||||
}
|
||||
|
||||
func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"service": {ServiceManagerSeat},
|
||||
"package": {PackageManagerSeat},
|
||||
"container": {ContainerRuntimeSeat},
|
||||
// The host's own acts, or the mesh's: nothing in between holds a role for them.
|
||||
"file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil,
|
||||
"action": nil, "network": nil, "access": nil,
|
||||
}
|
||||
for kind, want := range cases {
|
||||
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
|
||||
}
|
||||
}
|
||||
all := DependsOn(withResources(mod("m", nil, nil, nil),
|
||||
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
|
||||
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
|
||||
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
|
||||
for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} {
|
||||
s, ok := SeatNamed(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the mesh's set", name)
|
||||
}
|
||||
if s.Scope != ScopeNode {
|
||||
t.Errorf("%s is held per %s, want per node", name, s.Scope)
|
||||
}
|
||||
}
|
||||
// No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and
|
||||
// the runtime's verbs wait for ADR 0166's acceptance.
|
||||
for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} {
|
||||
if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 {
|
||||
t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("a node holding all three seats reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
|
||||
t.Errorf("assigning beside the three holders was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
// What the node does hold is not named as missing.
|
||||
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
|
||||
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADependencyNoCatalogueModuleCanMeetIsSaidNeverRefused(t *testing.T) {
|
||||
// No runtime module in the catalogue: refusing would stop every container's assignment until one
|
||||
// is written, with no remedy to name — so it is said, with the switch on as with it off.
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(web)
|
||||
for _, on := range []bool{false, true} {
|
||||
enforceSeatDependencies = on
|
||||
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
|
||||
if err != nil {
|
||||
t.Fatalf("switch %v: a dependency nothing could meet was refused: %v", on, err)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
|
||||
t.Errorf("switch %v: the assignment does not say what it depends on: %v", on, said)
|
||||
}
|
||||
if _, err := Resolve(cat, []string{"web"}, workstation(), World{}); err != nil {
|
||||
t.Errorf("switch %v: a dependency nothing could meet refused the node: %v", on, err)
|
||||
}
|
||||
}
|
||||
enforceSeatDependencies = true
|
||||
}
|
||||
|
||||
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
|
||||
cat := shelf(coreThree()...)
|
||||
// Alone, each needs the other.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "pacman") {
|
||||
t.Errorf("systemd alone was not refused naming pacman: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "systemd") {
|
||||
t.Errorf("pacman alone was not refused naming systemd: %v", err)
|
||||
}
|
||||
// Together, in one act, they meet each other — and docker meets its own seat.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
|
||||
t.Errorf("the three holders assigned together were refused: %v", err)
|
||||
}
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("systemd and pacman together report %v", got.Unheld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
|
||||
// Before the switch (the state the mesh ran in until every node held the three seats).
|
||||
enforceSeatDependencies = false
|
||||
defer func() { enforceSeatDependencies = true }()
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
got, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("an unmet dependency refused the node before the switch: %v", err)
|
||||
}
|
||||
want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}}
|
||||
if !reflect.DeepEqual(got.Unheld, want) {
|
||||
t.Errorf("reported %+v, want %+v", got.Unheld, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
|
||||
enforceSeatDependencies = true
|
||||
defer func() { enforceSeatDependencies = true }()
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
_, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") {
|
||||
t.Fatalf("with the switch on, an unmet dependency gave %v", err)
|
||||
}
|
||||
// Never in the first pass, whose refusals take a machine off the network instead.
|
||||
if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil {
|
||||
t.Errorf("the first pass refused an unmet dependency: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||
// The private network, as the controller computes it: its tools' package and its service are
|
||||
// the mesh's, written per node, and so are never a module's dependency.
|
||||
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
|
||||
res("package", "wireguard-tools"), res("service", "overlay-up"))
|
||||
network.Computed = "mesh-wireguard"
|
||||
// The host, whatever it declares.
|
||||
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
|
||||
cat := shelf(append(coreThree(), network, host)...)
|
||||
|
||||
for _, m := range []Manifest{network, host} {
|
||||
if d := DependsOn(m); len(d) != 0 {
|
||||
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||
}
|
||||
}
|
||||
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
|
||||
t.Errorf("assigning the foundation was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||
cat := shelf(append(coreThree(), sshd)...)
|
||||
on := []string{"systemd", "pacman", "docker", "sshd"}
|
||||
|
||||
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
|
||||
if err == nil {
|
||||
t.Fatal("the last service manager came off a node still running services")
|
||||
}
|
||||
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
// A dependent comes off freely, and the holders with everything depending on them in one act.
|
||||
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
|
||||
t.Errorf("a dependent's unassignment was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
|
||||
t.Errorf("unassigning everything together was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ type Seat struct {
|
||||
// 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{
|
||||
var defaultSeats = append([]Seat{
|
||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||
// so no work queue is raised for it — only what its holder may say.
|
||||
@@ -148,8 +148,19 @@ var defaultSeats = []Seat{
|
||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||
// served by the node tools runtime (ADR 0175).
|
||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||
Serves: serviceManagerVerbs()},
|
||||
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
||||
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
||||
// mesh's word for "something on this machine answers for installing", where a capability only
|
||||
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
||||
// of it is decided when someone needs to ask.
|
||||
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
|
||||
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
||||
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
||||
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
||||
// dependency has a seat to name and the runtime's module has one to claim.
|
||||
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
||||
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
||||
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
||||
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
||||
@@ -161,6 +172,10 @@ var defaultSeats = []Seat{
|
||||
// node may call; the holder places every module's shell code in its slots.
|
||||
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||
Serves: loginShellVerbs()},
|
||||
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
||||
// code modules contribute for the power moments, and publishes the machine's power states as
|
||||
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
|
||||
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
|
||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||
// model change, not a rename, so it stays until that is built.
|
||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||
@@ -172,7 +187,9 @@ var defaultSeats = []Seat{
|
||||
// rather than a condition in the resolver's module, so a machine running two managers is
|
||||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
|
||||
}
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
|
||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
||||
// and a row in to-be 26, not a new number here.
|
||||
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
record := regexp.MustCompile(`^novox/hq ADR \d{4}$`)
|
||||
// A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined
|
||||
// it and the one that seeded it.
|
||||
record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`)
|
||||
seen := map[string]bool{}
|
||||
delivered := map[string]string{}
|
||||
for _, s := range Seats() {
|
||||
@@ -44,11 +46,13 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Nineteen since node-environment and node-login-shell (novox/hq ADR 0203, ADR 0204), after
|
||||
// node-build-agent made seventeen (ADR 0190) — eighteen once the retired mesh-build-machine row
|
||||
// goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 19 {
|
||||
t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+
|
||||
// Thirty-three with node-power (novox/hq ADR 0211); thirty-two since the graphical session's
|
||||
// eleven (ADR 0208); twenty-one with
|
||||
// node-package-manager and node-container-runtime (ADR 0207); nineteen with node-environment and
|
||||
// node-login-shell (ADR 0203, ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer
|
||||
// once the retired mesh-build-machine row goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 33 {
|
||||
t.Errorf("the mesh defines %d seats rather than 33; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One account, several modules: the shell's module sets its shell, the container runtime's adds it
|
||||
// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is
|
||||
// one value, owned by one module per node.
|
||||
|
||||
func userResource(fields map[string]any) map[string]any {
|
||||
r := map[string]any{"id": "operator", "type": "user", "name": "op"}
|
||||
for k, v := range fields {
|
||||
r[k] = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) {
|
||||
zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}}
|
||||
docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}}
|
||||
other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}}
|
||||
if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 {
|
||||
t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems)
|
||||
}
|
||||
if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil {
|
||||
t.Fatalf("the three did not resolve together: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) {
|
||||
for _, field := range []string{"shell", "home"} {
|
||||
a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}}
|
||||
b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}}
|
||||
problems := checkResources([]Manifest{a, b})
|
||||
want := `zsh and fish both set the ` + field + ` of the user "op"`
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], want) {
|
||||
t.Errorf("two modules setting %s gave %v, want %q", field, problems, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29): every other node's
|
||||
// Host block written into a marked region of the operator's ~/.ssh/config, owned by the account,
|
||||
// with ~/.ssh created 0700 — the operator's own config kept.
|
||||
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a
|
||||
// region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first,
|
||||
// because ssh takes the first value it finds for each option — and the mesh's Host blocks as the
|
||||
// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d
|
||||
// are created 0700 and owned by the account; the operator's own config below the region is kept.
|
||||
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
||||
shelf := shelf(catalogueManifest(t, "ssh-client"))
|
||||
got, err := Resolve(shelf, []string{"ssh-client"},
|
||||
Node{Name: "homer", At: "homer.internal", Account: "jo"}, World{})
|
||||
Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2"}
|
||||
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2",
|
||||
"bart.internal": "10.10.0.3"}
|
||||
out, err := got.Declaration(Rendering{
|
||||
Names: names, Machines: names, Accounts: map[string]string{"homer": "jo", "marge": "jo"},
|
||||
Suffix: "internal",
|
||||
Names: names, Machines: names,
|
||||
Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"},
|
||||
Suffix: "internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -28,19 +32,39 @@ func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
||||
by[r["id"].(string)] = r
|
||||
}
|
||||
|
||||
dir := by["ssh-client.ssh-dir"]
|
||||
if dir == nil || dir["path"] != "/home/jo/.ssh" || dir["owner"] != "jo" || dir["mode"] != "0700" {
|
||||
t.Fatalf("~/.ssh is not created 0700 owned by the account: %v", dir)
|
||||
for id, path := range map[string]string{
|
||||
"ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} {
|
||||
dir := by[id]
|
||||
if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" {
|
||||
t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir)
|
||||
}
|
||||
}
|
||||
cfg := by["ssh-client.fact-ssh-config"]
|
||||
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || cfg["into"] != "block" {
|
||||
t.Fatalf("the ssh config is not written into the operator's ~/.ssh/config as a region: %v", cfg)
|
||||
|
||||
cfg := by["ssh-client.config"]
|
||||
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" ||
|
||||
cfg["into"] != "block" || cfg["at"] != "start" {
|
||||
t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg)
|
||||
}
|
||||
body := cfg["content"].(string)
|
||||
if !strings.Contains(body, "Host marge marge.internal") || !strings.Contains(body, "User jo") {
|
||||
t.Fatalf("the config does not name the peer node and its account:\n%s", body)
|
||||
if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") {
|
||||
t.Fatalf("the region does not just include config.d:\n%s", body)
|
||||
}
|
||||
|
||||
var hosts map[string]any
|
||||
for _, r := range out {
|
||||
if r["path"] == "/home/jo/.ssh/config.d/00-mesh" {
|
||||
hosts = r
|
||||
}
|
||||
}
|
||||
if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil {
|
||||
t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts)
|
||||
}
|
||||
body := hosts["content"].(string)
|
||||
for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Host homer ") {
|
||||
t.Fatalf("the config names the machine itself, not only its peers:\n%s", body)
|
||||
t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,13 @@ var ControllerVerbs = []Verb{
|
||||
}, nil)},
|
||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||
{Name: "unassign", Description: "Take a module off a machine.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
||||
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
|
||||
Input: schema(map[string]string{"node": "the machine's name",
|
||||
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
||||
Input: schema(map[string]string{"node": "the machine's name",
|
||||
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
||||
Input: schema(map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user