An act says the unmet seat dependencies of the node it acted on, not the mesh's (hq ADR 0207)
assign and unassign say only what they changed on their node; push <node> lists that node's, push to many counts each and points at status. The once-per-change log is the serving controller's alone: a one-shot command starts with no memory, so it logged every node on every call.
This commit is contained in:
+32
-12
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
|||||||
// — the service manager, the package manager and the runtime before anything that installs,
|
// — 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
|
// 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.
|
// holders that depend on each other go on in one command.
|
||||||
said, err := seatDependenciesOnAssign(ctx, open, node, modules)
|
shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -91,8 +92,11 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
|||||||
for _, line := range settled {
|
for _, line := range settled {
|
||||||
answer += "\n " + line
|
answer += "\n " + line
|
||||||
}
|
}
|
||||||
for _, line := range said {
|
// What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq
|
||||||
answer += "\n but " + line
|
// 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
|
// 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
|
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
||||||
@@ -128,17 +132,18 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
|||||||
blockedElsewhere(ctx, open, node), nil
|
blockedElsewhere(ctx, open, node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or the lines an assignment
|
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the
|
||||||
// says beside itself when a dependency has no holder in the catalogue to name. Modules already
|
// catalogue and the node's assignments it was judged against. Modules already assigned are not new
|
||||||
// assigned are not new and are not judged again.
|
// and are not judged again.
|
||||||
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) ([]string, error) {
|
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) (
|
||||||
|
map[string]catalogue.Manifest, []string, error) {
|
||||||
shelf, err := open.inventory.Catalogue(ctx)
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
assigned, err := open.inventory.Assigned(ctx, node)
|
assigned, err := open.inventory.Assigned(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
already := map[string]bool{}
|
already := map[string]bool{}
|
||||||
for _, a := range assigned {
|
for _, a := range assigned {
|
||||||
@@ -150,7 +155,12 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
|
|||||||
adding = append(adding, m)
|
adding = append(adding, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return catalogue.AssignRefusal(shelf, node, assigned, adding)
|
// 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
|
||||||
|
}
|
||||||
|
return shelf, assigned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unassign takes modules off a node. What they leave behind is the host's business: a directory
|
// unassign takes modules off a node. What they leave behind is the host's business: a directory
|
||||||
@@ -198,8 +208,18 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||||
node, strings.Join(modules, ", "), node) + blockedElsewhere(ctx, open, node), nil
|
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,
|
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -1329,16 +1330,25 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says
|
// 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 — planFor runs for every status, push and assignment, and a
|
// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document.
|
||||||
// line per call would bury the one that changed.
|
//
|
||||||
|
// **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 (
|
var (
|
||||||
unheldLogged = map[string]string{}
|
unheldLogged = map[string]string{}
|
||||||
unheldLoggedMu sync.Mutex
|
unheldLoggedMu sync.Mutex
|
||||||
|
logUnheldChanges bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
|
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
|
||||||
// it, including when they become none.
|
// it, including when they become none — in the serving controller, and nowhere else.
|
||||||
func logUnheld(node string, unheld []catalogue.Unheld) {
|
func logUnheld(node string, unheld []catalogue.Unheld) {
|
||||||
|
if !logUnheldChanges {
|
||||||
|
return
|
||||||
|
}
|
||||||
lines := make([]string, 0, len(unheld))
|
lines := make([]string, 0, len(unheld))
|
||||||
for _, u := range unheld {
|
for _, u := range unheld {
|
||||||
lines = append(lines, u.String())
|
lines = append(lines, u.String())
|
||||||
@@ -1358,3 +1368,50 @@ func logUnheld(node string, unheld []catalogue.Unheld) {
|
|||||||
fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
|
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 "))
|
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"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -62,6 +63,9 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serve(ctx context.Context) error {
|
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)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -360,6 +364,9 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) {
|
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
||||||
plan, settings, err := planFor(held, open, node)
|
plan, settings, err := planFor(held, open, node)
|
||||||
if err != nil {
|
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
|
// 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.
|
// dropped — the remedy is to move it, and until then the rest of the node converges.
|
||||||
reportUnhostable(node, plan)
|
reportUnhostable(node, plan)
|
||||||
|
unheld[node] = plan.Unheld
|
||||||
// The private network is in here with everything else. It used to be composed separately
|
// 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
|
// 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.
|
// 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()
|
release()
|
||||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
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
|
// 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.
|
// operators run, and on 2026-10-01 it was the one path that issued none.
|
||||||
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
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
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user