From fa0a5f1c884321593ad008492c57a590ccc85aba Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 21:57:29 +0200 Subject: [PATCH] Carry over the ADR 0325 controller work as the base for ADR 0328 --- cmd/mesh-controller/asker.go | 25 +- cmd/mesh-controller/asker_wire.go | 21 + cmd/mesh-controller/check.go | 10 + cmd/mesh-controller/gate.go | 56 ++ cmd/mesh-controller/kept_folders.go | 394 ++++++++++++ cmd/mesh-controller/kept_folders_test.go | 563 +++++++++++++++++ cmd/mesh-controller/module_health.go | 44 +- cmd/mesh-controller/modules.go | 9 + cmd/mesh-controller/operator_wait.go | 107 +++- cmd/mesh-controller/plan.go | 85 ++- cmd/mesh-controller/sendable.go | 6 + go.mod | 2 +- go.sum | 4 +- internal/broker/nats.go | 9 +- internal/broker/testdata/composed.conf | 2 +- internal/broker/writers.go | 5 + internal/broker/writers_test.go | 1 + internal/builder/check.go | 6 +- internal/catalogue/declaration.go | 58 +- internal/catalogue/manifest.go | 2 + internal/catalogue/module_identity.go | 575 ++++++++++++++++++ internal/catalogue/module_identity_test.go | 370 +++++++++++ internal/catalogue/placement.go | 35 +- internal/catalogue/settings.go | 12 + internal/inventory/catalogue.go | 68 ++- internal/inventory/health.go | 28 +- internal/inventory/identities.go | 127 ++++ internal/inventory/identities_test.go | 224 +++++++ ...iven-an-identity-and-a-folder-is-taken.sql | 44 ++ internal/link/protocol.go | 65 ++ internal/link/take.go | 114 ++++ testdata/beside/CAPTURED | 2 +- .../apply/placement_guard.go.captured | 14 + testdata/store-tests | 6 + .../internal/declaration/declaration.go | 212 ++++++- vendor/modules.txt | 4 +- 36 files changed, 3276 insertions(+), 33 deletions(-) create mode 100644 cmd/mesh-controller/kept_folders.go create mode 100644 cmd/mesh-controller/kept_folders_test.go create mode 100644 internal/catalogue/module_identity.go create mode 100644 internal/catalogue/module_identity_test.go create mode 100644 internal/inventory/identities.go create mode 100644 internal/inventory/identities_test.go create mode 100644 internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql create mode 100644 internal/link/take.go diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 4bf3b117..d9a43c4f 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -117,10 +117,13 @@ type asked struct { // Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no // condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions. Proposal *settingsProposal `json:"proposal,omitempty"` + // Take is a shared folder the operator is asked to let the mesh keep (kept_folders.go, novox/hq ADR 0325 §8): + // about no condition, taken on Approve by the serving controller itself, asked once per folder and machine. + Take *folderTake `json:"take,omitempty"` } // ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal. -func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil } +func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil && r.Take == nil } // partKey is an ask's place among what is asked: its condition and its part. func partKey(condition, part string) string { return condition + "#" + part } @@ -192,6 +195,11 @@ type asker struct { call func(ctx context.Context, a conditions.Action, args map[string]string) error // setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot. setLayer setOnWarrant + // folders is every shared folder waiting to be kept (novox/hq ADR 0325 §8), and take what takes one on the + // operator's warrant; nil asks about none. machines are the mesh's machine names, allowed in an ask's words. + folders func(ctx context.Context) ([]folderTake, error) + take takeOnWarrant + machines func(ctx context.Context) ([]string, error) // record writes the hand-act log. record func(ctx context.Context, act link.HandAct) error // routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing. @@ -554,6 +562,10 @@ func (a *asker) reconcile(ctx context.Context) error { return err } } + // And every shared folder waiting to be kept, once per folder and machine (novox/hq ADR 0325 §8). + if err := a.askFolders(ctx); err != nil { + a.logf("the operator could not be asked about the folders waiting to be kept: %v", err) + } why := "the router refused the ask" if len(refusedWords) > 0 { why += ": " + refusedWords[0] @@ -818,7 +830,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { if err != nil { return err } - stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition + stillOpen := r.Rehearsal || r.Proposal != nil || r.Take != nil // a rehearsal, a proposal and a take are about no condition for _, c := range open { stillOpen = stillOpen || c.Key == r.Condition } @@ -876,6 +888,15 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") { outcome = "done: " + outcome } + case r.Take != nil && act.Verb == takeVerb: + // A shared folder taken on the operator's warrant (novox/hq ADR 0325 §8): the act is composed again from the + // record and held to what the option bound before the engine is asked. + outcome, acted = a.decideTake(ctx, *r, act, w) + if acted == nil && !strings.HasPrefix(outcome, "nothing") { + outcome = "done: " + outcome + } + case r.Take != nil: + acted = fmt.Errorf("the take %s offers no act %s", r.ID, act.Verb) case act.Arguments["silence"] != "": acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why) default: diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go index aadff952..c4bd0d13 100644 --- a/cmd/mesh-controller/asker_wire.go +++ b/cmd/mesh-controller/asker_wire.go @@ -572,6 +572,27 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper call: callAction(conn), // A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277). setLayer: setLayerIn(open), + // A shared folder waiting to be kept is asked about once, and taken on the warrant (novox/hq ADR 0325 §8). + folders: foldersIn(open), + take: takeFolderIn(open, func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error) { + ident, err := open.Identity(ctx) + if err != nil { + return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of "+ + "%s: %w", act.Node, err) + } + return link.AskTake(ctx, conn, ident, act, link.TakeWithin) + }), + machines: func(ctx context.Context) ([]string, error) { + nodes, err := open.inventory.Nodes(ctx) + if err != nil { + return nil, err + } + names := make([]string, 0, len(nodes)) + for _, n := range nodes { + names = append(names, n.Name) + } + return names, nil + }, record: func(ctx context.Context, act link.HandAct) error { _, err := link.RecordHandAct(ctx, conn, act) return err diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index 4ccf7b5f..d40e05c9 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -152,6 +152,16 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { } } + // **What ADR 0325 refuses once a module runs as its own identity is warned of until then** (novox/hq ADR 0325 §1–§3): + // a directory naming its owner, a container declaring no identity that mounts the module's own directory or a + // read-write access. Printed, never counted: every registered container module does one of them today, and the + // catalogues migrate against these lines. A module that declares identity is refused them instead (ParseManifest). + for _, name := range names { + for _, w := range catalogue.IdentityWarnings(shelf[name]) { + fmt.Fprintf(out, "warning: %s\n", w) + } + } + // **A file that asks for a setting and does not say whether it is trusted counts as trusted** (novox/hq issue // 339): listed and counted, never refused, so an author can opt out a file nothing trusts. unsaid := 0 diff --git a/cmd/mesh-controller/gate.go b/cmd/mesh-controller/gate.go index f5aa1689..73df4362 100644 --- a/cmd/mesh-controller/gate.go +++ b/cmd/mesh-controller/gate.go @@ -154,6 +154,10 @@ type gateFacts struct { // groupsAdded is, per module, whether the move judged puts an account in a group its previous build did // not (issue 318 review): the only move whose wait for a new login is excused. groupsAdded map[string]bool + // accessesChanged is, per module, whether the move judged changes its accesses (novox/hq ADR 0325): an access the + // engine cannot judge this look (unknown), or entries it left while a disk was away, of a module whose move leaves + // its accesses as they were is no fault of the move. A module absent here is taken to change them: judged as before. + accessesChanged map[string]bool // waits is what the controller holds to check a module's wait for the operator (novox/hq ADR 0283): the // manifest of each module's build judged, and the secrets given on each machine. waits operatorWaitFacts @@ -528,6 +532,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs return "", err } facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf) + facts.accessesChanged = movesChangingAccesses(ctx, open.inventory, g, pairs, shelf) facts.waits = gateWaitFacts(ctx, open.inventory, g, pairs, shelf, facts.health) facts.sent = g.Sent facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{} @@ -783,6 +788,57 @@ func joinSaid(a, b string) string { return a + "; " + b } +// movesChangingAccesses is, per module a gate judges, whether its move changes its accesses — one added, dropped, +// or given another use — read from the builds' manifests (novox/hq ADR 0325). A move not known changes them: an +// access it cannot judge then holds the gate to its bound, and never passes unseen. +func movesChangingAccesses(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged, + shelf map[string]catalogue.Manifest) map[string]bool { + out := map[string]bool{} + for _, j := range pairs { + if _, done := out[j.module]; done { + continue + } + from, to := g.From, g.To + for _, c := range g.Carried { + if c.Module == j.module { + from, to = c.From, c.To + break + } + } + target, found, err := inv.ManifestAt(ctx, j.module, to) + if err != nil || !found { + target = shelf[j.module] + } + before, had, err := inv.ManifestAt(ctx, j.module, from) + if from == "" || err != nil || !had { + out[j.module] = true + continue + } + out[j.module] = !sameAccessUse(before, target) + } + return out +} + +// sameAccessUse says a move leaves a module's accesses as they were, users included (the review of e1c2b744): the +// same accesses at the same use, and the same containers declaring identity and mounting each — a container that +// starts or stops running as the module's identity changes who uses the folder. +func sameAccessUse(a, b catalogue.Manifest) bool { + return sameAccesses(a.Accesses, b.Accesses) && catalogue.AccessUsersShape(a) == catalogue.AccessUsersShape(b) +} + +// sameAccesses says two manifests declare the same accesses, at the same use, in any order. +func sameAccesses(a, b []catalogue.Access) bool { + key := func(as []catalogue.Access) string { + var out []string + for _, x := range as { + out = append(out, x.ID+"\x00"+x.Path+"\x00"+x.At()) + } + sort.Strings(out) + return strings.Join(out, "\x01") + } + return key(a) == key(b) +} + // movesAddingGroups is, per module a gate judges, whether its move puts an account in a group the build it // moved from did not: read from the builds' manifests. A module whose move is not known adds none. func movesAddingGroups(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged, diff --git a/cmd/mesh-controller/kept_folders.go b/cmd/mesh-controller/kept_folders.go new file mode 100644 index 00000000..369017d9 --- /dev/null +++ b/cmd/mesh-controller/kept_folders.go @@ -0,0 +1,394 @@ +package main + +// The mesh takes a shared folder that already holds data once, on the operator's word (novox/hq ADR 0325 §7, §8). +// +// **What waits.** A node-engine that keeps folders states an access waiting, naming it (inventory.Wait.Access), while +// its folder is there and has not been taken; the controller excuses that wait only for a declared, placed folder not +// yet taken (checkAccessWait), and the module is said as its needs-operator condition, in words with no path. +// +// **One ask per folder and machine.** The serving controller's asker asks the operator once for each such folder on +// each machine — however many modules wait on it — at the level approve (ADR 0259), in the clause's words: "Let the +// mesh keep on , now for : …". It is kept in the controller's asks beside a proposal +// (asked.Take), never a condition's: the condition is the module's, and two modules waiting on one folder are one +// question. While it is open no second is asked; one that expired unanswered is asked again while the folder waits; +// one about a folder that no longer waits is taken back. None is asked for a folder already taken, so a module that +// joins a taken folder is asked nothing (its access wait is not excused, and its entries are added at apply). +// +// **On the warrant, and only on it**, the controller asks the machine's engine to take the folder (link.AskTake), +// naming the access by its resource id and the warrant; the engine adds the entries of the users it was declared for +// that folder. On its word that it started, the folder is recorded taken (kept_folder), and from the next send every +// access to it says `taken`. Where the engine's wait says the filesystem must first allow ACLs (acl +// "needs-enabling"), the ask says the take sets that; where it cannot carry them (acl "unsupported") the wait is not +// excused and nothing is asked. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/outward" +) + +// folderTake is one folder the operator is asked to let the mesh keep, as the controller's ask keeps it +// (asked.Take). Every field the ask is composed from is here, so the act on the warrant is composed from the record. +type folderTake struct { + Node string `json:"node"` + Path string `json:"path"` + // Access is the resource id of one access to it in the machine's declaration: what the take act names. + Access string `json:"access"` + // Accesses are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted. + Accesses []string `json:"accesses"` + Modules []string `json:"modules"` + // EnablesACL says the folder's filesystem must first allow access entries (a ZFS dataset's acltype), which the + // take act sets: the ask says so. + EnablesACL bool `json:"enables-acl,omitempty"` + // Placed is the filesystem the folder is on, as the engine names it without a path: the warrant is for that one. + Placed string `json:"placed,omitempty"` + At time.Time `json:"at"` +} + +// takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant. +const takeVerb = askerName + ".take" + +// key is the folder on its machine: what one ask is kept for. +// The filesystem is part of it: a folder that moved to another disk is asked about again. +func (t folderTake) key() string { return t.Node + "\x00" + filepath.Clean(t.Path) + "\x00" + t.Placed } + +// digest binds everything the take does and was said for: the machine, the folder, the access it names and the +// modules the operator was told. +func (t folderTake) digest() string { + raw, _ := json.Marshal(struct { + Node, Path, Access, Placed string + Modules []string + EnablesACL bool + }{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules, t.EnablesACL}) + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// actions is the take's one answer, at the level approve, binding the exact act through boundAct. +func (t folderTake) actions(id string) []conditions.Action { + return []conditions.Action{{Label: "Let the mesh keep it", Verb: takeVerb, Machine: t.Node, + Level: conditions.LevelApprove, Arguments: map[string]string{"take": id, "node": t.Node, + "path": filepath.Clean(t.Path), "access": t.Access, "digest": t.digest()}}} +} + +// takeWords is the clause's ask (ADR 0325 §8), with the folder as shown: "Let the mesh keep on , now +// for : …". +func takeWords(shown, machine string, modules []string, enablesACL bool) string { + words := fmt.Sprintf("Let the mesh keep %s on %s, now for %s: it adds their access entries, and those of any "+ + "module given access later, to every folder and file there; the group bits `ls -l` shows become the "+ + "entries' mask, and owners, groups and every other right stay as they are", shown, machine, + strings.Join(modules, ", ")) + if enablesACL { + words += ". Its filesystem does not allow access entries yet (a ZFS dataset's acltype): approving sets that first" + } + return words +} + +// ask is the take's ask, composed from it alone. The folder's path is said whole where the router's content rule +// lets it leave the mesh, and otherwise named by the shared folder it is ("the shared folder movies"), which is the +// access the module's wait names. +func (t folderTake) ask(id string, machines []string) (asks.Ask, map[string]int) { + shown := filepath.Clean(t.Path) + if _, ok := outward.Check(shown, machines...); !ok { + shown = "the shared folder " + strings.Join(t.Accesses, ", ") + } + headline := fmt.Sprintf("Let the mesh keep a shared folder on %s?", t.Node) + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Let the mesh keep a shared folder?" + } + explanation := takeWords(shown, t.Node, t.Modules, t.EnablesACL) + "." + if _, ok := outward.Check(explanation, machines...); !ok { + explanation = sayable(explanation, machines) + } + q := asks.Ask{ID: id, Headline: headline, Explanation: explanation, + Who: asks.Operator, Expires: t.At.Add(askApproveFor), + OnExpiry: "nothing is changed, and you are asked again while the folder waits", About: "take." + t.Node} + options := map[string]int{} + for i, act := range t.actions(id) { + binds, _ := asks.ActDigest(boundAct(act)) + oid := optionID(act.Label) + options[oid] = i + q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Level: asks.Level(act.Level), Binds: binds, + Does: "the mesh adds the access entries there, and keeps them from then on without asking"}) + } + return q, options +} + +// takeOnWarrant performs a take the operator approved: the engine is asked, and the folder recorded taken on its +// word. It answers what the engine said. +type takeOnWarrant func(ctx context.Context, t folderTake, askID, by string) (string, error) + +// askFolders asks the operator about every folder waiting to be kept that is not asked about yet, and takes back an +// open ask about one that no longer waits. Called at the end of each reconcile, after the conditions' asks, within +// the asks the router holds open at once. +func (a *asker) askFolders(ctx context.Context) error { + if a.folders == nil { + return nil + } + wanted, err := a.folders(ctx) + if err != nil { + return err + } + now := a.now() + all, err := a.store.All(ctx) + if err != nil { + return err + } + open, openNow := map[string]asked{}, 0 + for _, r := range all { + if r.State != askOpen || !now.Before(r.Ask.Expires) { + continue + } + openNow++ + if r.Take != nil { + open[r.Take.key()] = r + } + } + want := map[string]bool{} + for _, t := range wanted { + want[t.key()] = true + } + for key, r := range open { + if !want[key] { + if err := a.cancel(ctx, r, "the folder no longer waits to be kept"); err != nil { + return err + } + openNow-- + } + } + var machines []string + if a.machines != nil { + if machines, err = a.machines(ctx); err != nil { + return err + } + } + for _, t := range wanted { + if _, asking := open[t.key()]; asking { + continue // one ask per folder and machine (§8) + } + if openNow >= askMostOpen { + return nil // asked when one of the open ones ends + } + t.At = now + id := newAskID() + q, options := t.ask(id, machines) + if err := q.Check(now); err != nil { + a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err) + continue + } + body, err := json.Marshal(q) + if err != nil { + return err + } + take := t + if err := a.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: t.actions(id), + Options: options, State: askOpen, Opened: now, Take: &take}); err != nil { + return fmt.Errorf("the take could not be kept in the controller's asks, so it was not asked: %w", err) + } + if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil { + _, _ = a.store.Change(ctx, id, func(x *asked) bool { + if x.State != askOpen || x.Acted != "" { + return false + } + x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error() + return true + }) + a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err) + continue + } + openNow++ + a.logf("asked the operator to let the mesh keep a folder on %s for %s (%s)", t.Node, + strings.Join(t.Modules, ", "), id) + } + return nil +} + +// decideTake is what the asker does with a warrant for a take (asker.Decided): the act only when it is the one the +// option bound — composed again from the record, the users' digest among it — and then the engine is asked. +func (a *asker) decideTake(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) { + t := *r.Take + recomposed := t.actions(r.ID) + option, offered := r.Ask.Option(w.Option) + if !offered || len(recomposed) != 1 { + return "", fmt.Errorf("the take %s offers no answer %q: nothing is taken", r.ID, w.Option) + } + if err := option.Performs(boundAct(recomposed[0])); err != nil { + return "", fmt.Errorf("the take kept for %s is not the one the operator was shown: %v", r.ID, err) + } + if err := option.Performs(boundAct(act)); err != nil { + return "", fmt.Errorf("the act chosen for %s is not the one the operator was shown: %v", r.ID, err) + } + if a.take == nil { + return "", errors.New("this controller cannot take a folder on a warrant") + } + // **The folder must still wait on the filesystem the operator was asked about** (the review of e1c2b744): a take + // is about no condition, so nothing else rechecks it. A folder that moved to another filesystem, or no longer + // waits for the operator's word, is not taken: the ask is spent, and one for the filesystem it is on now is asked. + if a.folders != nil { + waiting, err := a.folders(ctx) + if err != nil { + return "", fmt.Errorf("whether the folder still waits on %s could not be read, so nothing is taken: %w", + t.Node, err) + } + still := false + for _, now := range waiting { + still = still || now.key() == t.key() + } + if !still { + return "nothing: the folder no longer waits to be kept on the filesystem the operator was asked about " + + "(it moved, or was taken or released meanwhile); asked again where it waits now", nil + } + } + by := fmt.Sprintf("approved by %s via %s at %s (ask %s)", byWords(w), viaWords(w), + w.At.Local().Format("2006-01-02 15:04"), r.ID) + return a.take(ctx, t, r.ID, by) +} + +// takeFolderIn is takeOnWarrant on this controller's stores and bus: the engine is asked with the mesh's key, naming the +// ask the warrant answered; on its word that it started, the folder is recorded taken. A refusal records nothing. +func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error)) takeOnWarrant { + return func(ctx context.Context, t folderTake, askID, by string) (string, error) { + // Who asked, as one short line the engine takes (at most link.MaxHandOverBy): the ask names the rest. + short := by + if len(short) > 180 { + short = "the operator, on the warrant for ask " + askID + } + answer, err := ask(ctx, link.AccessActAsk{Node: t.Node, Path: filepath.Clean(t.Path), By: short, + Access: t.Access, Warrant: askID}) + if err != nil { + return "", err + } + if answer.Refused != "" { + return "", fmt.Errorf("%s refused to take the folder: %s", t.Node, answer.Refused) + } + if err := open.inventory.RecordTaken(ctx, t.Node, inventory.TakenFolder{Path: t.Path, By: by, Ask: askID, + Warrant: askID, Said: answer.Said, Placed: t.Placed}); err != nil { + return "", fmt.Errorf("%s started the take (%s), and it could NOT be recorded: %w", t.Node, answer.Said, err) + } + return fmt.Sprintf("%s takes the folder for %s: %s", t.Node, strings.Join(t.Modules, ", "), answer.Said), nil + } +} + +// waitingFolder is one access wait excused for a folder not yet taken, as foldersWaiting gathers them. +type waitingFolder struct { + module, access string + // resource is the access's resource id on the machine, as the engine stated it; placed the filesystem its folder + // is on, without its path. + resource, placed string + enablesACL bool +} + +// foldersWaiting is every folder waiting to be kept across the mesh (ADR 0325 §7, §8): from each machine's newest +// statement, each access wait of a module that is excused (checkWaiting) and waits for the operator's word rather +// than for a disk, grouped by machine and folder. +func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) ( + map[string]map[string][]waitingFolder, operatorWaitFacts) { + var f operatorWaitFacts + out := map[string]map[string][]waitingFolder{} + machines := make([]string, 0, len(healths)) + for m := range healths { + machines = append(machines, m) + } + sort.Strings(machines) + for _, machine := range machines { + rs := healths[machine].Resources + var modules []string + for _, r := range rs { + for _, w := range r.Waits { + if r.State == link.StateWaiting && w.Access != "" && !w.Disk { + modules = append(modules, r.Module) + } + } + } + if len(modules) == 0 { + continue + } + readWaitFacts(ctx, inv, machine, modules, nil, &f) + for _, r := range checkWaiting(machine, rs, f) { + if r.State != link.StateWaiting { + continue + } + for _, w := range r.Waits { + if w.Access == "" || w.Disk { + continue + } + path, placed := f.placed[r.Module+"@"+machine][w.Access] + if !placed { + continue + } + if out[machine] == nil { + out[machine] = map[string][]waitingFolder{} + } + out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access, + resource: r.Resource, placed: w.Placed, enablesACL: w.ACL == link.ACLNeedsEnabling}) + } + } + } + return out, f +} + +// foldersIn is the asker's folders on this controller's stores: every folder waiting to be kept, each with the +// modules waiting on it and the access the take names (the first waiting module's, by its resource id). Who the +// take adds entries for is what the engine was declared for that folder: the ask names the modules. +func foldersIn(open *stores) func(ctx context.Context) ([]folderTake, error) { + return func(ctx context.Context) ([]folderTake, error) { + healths, err := open.inventory.Healths(ctx) + if err != nil { + return nil, err + } + waiting, _ := foldersWaiting(ctx, open.inventory, healths) + return takesOf(waiting), nil + } +} + +// takesOf is one take per machine and folder from the waits gathered, sorted. +func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake { + var out []folderTake + for machine, folders := range waiting { + for path, all := range folders { + // One take per filesystem the waits name: they agree but for a moment while a disk is swapped. + byPlaced := map[string][]waitingFolder{} + for _, w := range all { + byPlaced[w.placed] = append(byPlaced[w.placed], w) + } + for placed, ws := range byPlaced { + t := folderTake{Node: machine, Path: path, Placed: placed} + seenM, seenA := map[string]bool{}, map[string]bool{} + var resources []string + for _, w := range ws { + if !seenM[w.module] { + seenM[w.module] = true + t.Modules = append(t.Modules, w.module) + } + if !seenA[w.access] { + seenA[w.access] = true + t.Accesses = append(t.Accesses, w.access) + } + resources = append(resources, w.resource) + t.EnablesACL = t.EnablesACL || w.enablesACL + } + sort.Strings(t.Modules) + sort.Strings(t.Accesses) + sort.Strings(resources) + t.Access = resources[0] + out = append(out, t) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() }) + return out +} diff --git a/cmd/mesh-controller/kept_folders_test.go b/cmd/mesh-controller/kept_folders_test.go new file mode 100644 index 00000000..e27b5998 --- /dev/null +++ b/cmd/mesh-controller/kept_folders_test.go @@ -0,0 +1,563 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// novox/hq ADR 0325 §7 and §8 on the controller's side: an access wait is excused only for a declared, placed folder +// not yet taken, or one whose disk is not there; the gate passes it as a wait for a person and the operator reads it +// as needs-operator in words with no path; the operator is asked once per folder and machine, never about a folder +// taken; and the folder is taken only on the warrant. + +var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1", + Accesses: []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}} + +var plexManifest = catalogue.Manifest{Module: "plex", Version: "1", + Accesses: []catalogue.Access{{ID: "films", Path: "/services/media/movies", Mode: catalogue.AccessRead}}} + +const moviesAt = "/storage/media/movies" + +func keepWait(access string) inventory.Wait { + return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep, + What: "let the mesh keep the shared folder " + access} +} + +func placedWait(access, placed string) inventory.Wait { + w := keepWait(access) + w.Placed = placed + return w +} + +func aclWait(access, acl string) inventory.Wait { + w := keepWait(access) + w.ACL = acl + return w +} + +func diskWait(access string) inventory.Wait { + return inventory.Wait{Part: "radarr", Access: access, Disk: true, Reason: link.WaitDisk, + What: "mount the disk that holds " + access} +} + +// accessFacts is the facts with each folder given taken on the filesystem fs-000000000001. +func accessFacts(taken ...string) operatorWaitFacts { + kept := map[string]string{} + for _, p := range taken { + kept[p] = "fs-000000000001" + } + return operatorWaitFacts{ + manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, + given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}}, + placed: map[string]map[string]string{"radarr@ace": {"movies": moviesAt}, "plex@ace": {"films": moviesAt}, + "radarr@novox": {}}, + taken: map[string]map[string]string{"ace": kept}, + } +} + +// Row 7: excused for a declared, placed folder not yet taken, and for an absent folder whose disk is not there (taken +// or not); judged unhealthy when undeclared, unplaced, already taken, or naming a secret beside it. That a folder is +// root's is the engine's to see: it states that unhealthy and never waiting (§12). +func TestAnAccessWaitIsExcusedOnlyForAFolderTheMeshMayNotKeepYet(t *testing.T) { + for _, c := range []struct { + name string + machine string + w inventory.Wait + f operatorWaitFacts + says string + }{ + {"declared, placed, not taken", "ace", keepWait("movies"), accessFacts(), ""}, + {"its disk not there", "ace", diskWait("movies"), accessFacts(), ""}, + {"its disk not there, the folder taken before", "ace", diskWait("movies"), accessFacts(moviesAt), ""}, + {"already taken", "ace", keepWait("movies"), accessFacts(moviesAt), "taken on ace already"}, + {"already taken on the filesystem it is on", "ace", placedWait("movies", "fs-000000000001"), + accessFacts(moviesAt), "taken on ace already"}, + {"taken on another filesystem: it moved, and is asked about again", "ace", + placedWait("movies", "fs-0000000000ff"), accessFacts(moviesAt), ""}, + {"undeclared", "ace", keepWait("books"), accessFacts(), "does not declare"}, + {"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"}, + {"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests, + placed: accessFacts().placed}, "could not be read"}, + {"its filesystem needs ACLs enabled: asked", "ace", aclWait("movies", link.ACLNeedsEnabling), accessFacts(), ""}, + {"its filesystem cannot carry ACLs: not asked", "ace", aclWait("movies", link.ACLUnsupported), accessFacts(), + "cannot carry access entries"}, + {"a reason the controller does not know", "ace", inventory.Wait{Part: "radarr", Access: "movies", + Reason: "later", What: "w"}, accessFacts(), "waits for the operator's word to keep its folder or for its disk"}, + {"an access and a secret", "ace", inventory.Wait{Part: "p", Access: "movies", Secret: "x", What: "w"}, + accessFacts(), "one secret or one setting, or one access"}, + } { + err := checkWait("radarr", c.machine, c.w, c.f) + switch { + case c.says == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says) + } + } +} + +func accessWaiting(module, access string, w inventory.Wait) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: module, Resource: module + "." + catalogue.AccessID("/services/media/movies"), + Kind: "access", Target: moviesAt, State: link.StateWaiting, Waits: []inventory.Wait{w}} +} + +// Row 7, the gate and the condition: both excused waits pass the first-node gate as a wait for a person, and the +// module is said as needs-operator — in words that carry no path. +func TestAnAccessWaitPassesTheGateAndAsksForTheOperatorWithoutAPath(t *testing.T) { + now := time.Now() + for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} { + w.What += " at " + moviesAt // an engine's words carrying a path never reach the operator's + f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", w)}}}} + h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f) + if h != healthPerson || !strings.Contains(why, "waits for the operator") { + t.Fatalf("an excused access wait reads %v %q; want a wait for a person", h, why) + } + if strings.Contains(why, "/") { + t.Errorf("the gate's verdict carries a path: %q", why) + } + o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil) + if o.Kind != kindNeedsOperator { + t.Fatalf("raised as %s", o.Kind) + } + for _, words := range []string{o.Summary, o.Headline, o.Explanation, o.Needs} { + if strings.Contains(words, "/") { + t.Errorf("the operator reads a path: %q", words) + } + } + want := "let the mesh keep the shared folder movies on ace when you are asked" + if w.Disk { + want = "mount the disk that holds movies on ace" + } + if !strings.Contains(o.Needs, want) { + t.Errorf("needs-operator asks %q; want %q", o.Needs, want) + } + } + // Taken already: not excused, and the gate does not pass it. + f := gateFacts{now: now, waits: accessFacts(moviesAt), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", keepWait("movies"))}}}} + if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet || + !strings.Contains(why, "taken") { + t.Fatalf("a wait on a taken folder reads %v %q", h, why) + } +} + +// waitingOn is foldersWaiting's gathering for one folder: the given modules waiting on it by their access. +func waitingOn(machine string, ws ...waitingFolder) map[string]map[string][]waitingFolder { + return map[string]map[string][]waitingFolder{machine: {moviesAt: ws}} +} + +func takeRig(t *testing.T, waiting func() map[string]map[string][]waitingFolder) (*askerRig, *[]string) { + r := newAskerRig(t) + var taken []string + r.a.folders = func(context.Context) ([]folderTake, error) { return takesOf(waiting()), nil } + r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil } + r.a.take = func(_ context.Context, ft folderTake, askID, by string) (string, error) { + taken = append(taken, ft.Node+" "+ft.Path+" "+ft.Access+" "+askID+" "+by) + return "taking", nil + } + return r, &taken +} + +func takeAsks(r *askerRig) []asked { + var out []asked + for _, a := range r.store { + if a.Take != nil { + out = append(out, a) + } + } + return out +} + +// Row 8, the controller's part: one ask per folder and machine however many modules wait on it, in the clause's +// words; none again while it is open; taken back when the folder no longer waits; none for a folder taken (its waits +// are not excused, so it is never gathered). +func TestTheOperatorIsAskedOncePerFolderAndMachine(t *testing.T) { + both := waitingOn("ace", + waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-services/media/movies"}, + waitingFolder{module: "plex", access: "films", resource: "plex.access-services/media/movies"}) + current := both + r, _ := takeRig(t, func() map[string]map[string][]waitingFolder { return current }) + ctx := context.Background() + for i := 0; i < 3; i++ { + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + } + kept := takeAsks(r) + if len(kept) != 1 { + t.Fatalf("%d asks for one folder on one machine", len(kept)) + } + q := kept[0].Ask + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } + if q.Highest() != asks.Approve || len(q.Options) != 1 { + t.Fatalf("the ask offers %+v; want one answer at approve", q.Options) + } + // The path may not leave the mesh (the router's content rule): the folder is named by the shared folder it is. + want := "Let the mesh keep the shared folder films, movies on ace, now for plex, radarr: it adds their access " + + "entries, and those of any module given access later, to every folder and file there; the group bits `ls -l` " + + "shows become the entries' mask, and owners, groups and every other right stay as they are." + if q.Explanation != want { + t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want) + } + if kept[0].Take.Access != "plex.access-services/media/movies" { + t.Errorf("the take names %q", kept[0].Take.Access) + } + + // The folder no longer waits (taken, or no module uses it): the open ask is taken back, and nothing is asked. + current = nil + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + if got := takeAsks(r); len(got) != 1 || got[0].State != askCancelled { + t.Fatalf("the ask about a folder that no longer waits: %+v", got) + } + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + if len(takeAsks(r)) != 1 { + t.Fatal("asked about a folder nothing waits on") + } + // A module joining a folder already taken is never gathered: its wait is not excused. + f := accessFacts(moviesAt) + if err := checkWait("radarr", "ace", keepWait("movies"), f); err == nil { + t.Fatal("a wait on a taken folder was excused, and would have been asked about") + } +} + +// Row 8: nothing is taken without the operator's warrant — an ask that ends without a choice, a warrant for another +// ask, or an act other than the one the option bound takes nothing; the warrant takes it once. +func TestAFolderIsTakenOnlyOnTheWarrant(t *testing.T) { + r, taken := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + a := takeAsks(r)[0] + if len(*taken) != 0 { + t.Fatal("taken before any warrant") + } + // A warrant for another asker's ask. + other := warrantOn(a, "let-the-mesh-keep-it", r.now) + other.Asker = "someone-else" + answerWith(t, r, other) + // The record changed after the ask: the act no longer the one bound. + changed := r.store[a.ID] + changed.Take = &folderTake{Node: "ace", Path: "/", Access: "radarr.access-movies", Modules: a.Take.Modules} + r.store[a.ID] = changed + answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now)) + if len(*taken) != 0 || !strings.HasPrefix(r.store[a.ID].Acted, "failed") { + t.Fatalf("a take whose record changed after the ask: %v, %q", *taken, r.store[a.ID].Acted) + } + + // Asked afresh, and the ask expires unanswered: nothing. + r2, taken2 := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + if err := r2.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + b := takeAsks(r2)[0] + expired := asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now} + answerWith(t, r2, expired) + if len(*taken2) != 0 { + t.Fatal("taken on an ask that expired") + } + + // The warrant: taken once, naming the access, the ask as the warrant, and who approved. + r3, taken3 := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + if err := r3.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + c := takeAsks(r3)[0] + w := warrantOn(c, "let-the-mesh-keep-it", r3.now) + answerWith(t, r3, w) + answerWith(t, r3, w) + if len(*taken3) != 1 || !strings.Contains((*taken3)[0], "ace "+moviesAt+" radarr.access-movies "+c.ID) || + !strings.Contains((*taken3)[0], "telegram") { + t.Fatalf("the warrant took %v", *taken3) + } + if !strings.HasPrefix(r3.store[c.ID].Acted, "done") { + t.Fatalf("the take was recorded %q", r3.store[c.ID].Acted) + } +} + +// The engine is asked only on a warrant, and the ask carries it: one with no warrant is never signed. +func TestATakeAskCarriesItsWarrantAndIsSigned(t *testing.T) { + _, key, _ := ed25519.GenerateKey(nil) + signer := keySigner{key} + ask := link.AccessActAsk{Node: "ace", Path: moviesAt, By: "the operator", Access: "radarr.access-services/media/movies"} + if _, err := link.SignTake(context.Background(), signer, ask); err == nil { + t.Fatal("a take with no warrant was signed") + } + ask.Warrant = "c0123456789abcdef" + body, err := link.SignTake(context.Background(), signer, ask) + if err != nil { + t.Fatal(err) + } + var signed link.SignedHandOver + if err := json.Unmarshal(body, &signed); err != nil { + t.Fatal(err) + } + if !ed25519.Verify(key.Public().(ed25519.PublicKey), append([]byte(link.AccessActContext), signed.Ask...), signed.Signature) { + t.Fatal("the take is not signed over its own context") + } + var got map[string]any + if err := json.Unmarshal(signed.Ask, &got); err != nil { + t.Fatal(err) + } + if got["act"] != "take" || got["warrant"] != "c0123456789abcdef" || got["access"] != ask.Access || + got["path"] != moviesAt || got["nonce"] == "" { + t.Fatalf("the ask carries %v", got) + } + // And the controller records it taken only on the engine's word. + refusing := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) { + return link.HandOverAnswer{Refused: "the disk that holds the folder is not there"}, nil + }) + if _, err := refusing(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil { + t.Fatal("a refused take was answered as done") + } + unreachable := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) { + return link.HandOverAnswer{}, errors.New("nothing on ace answers") + }) + if _, err := unreachable(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil { + t.Fatal("an unanswered take was answered as done") + } +} + +type keySigner struct{ key ed25519.PrivateKey } + +func (s keySigner) Sign(_ context.Context, b []byte) ([]byte, error) { + return ed25519.Sign(s.key, b), nil +} + +// The envelope carries `identities` only when composed — for an engine at link.IdentityContract — and an older +// engine's body is what it was. +func TestTheDeclarationSaysItsIdentitiesOnlyWhenComposed(t *testing.T) { + older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(older), "identities") { + t.Fatalf("an older engine's body names identities: %s", older) + } + newer, err := sendable{Resources: []map[string]any{{"id": "a"}}, Identities: []map[string]any{ + {"module": "radarr", "name": "mesh-radarr", "uid": 50000, "gid": 50000}}}.Body() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(newer), `"identities":[{"gid":50000,"module":"radarr","name":"mesh-radarr","uid":50000}]`) { + t.Fatalf("the body: %s", newer) + } + if link.IdentityContract != 5 { + t.Fatalf("the contract the engine states for ADR 0325 is 5, and this controller reads %d", link.IdentityContract) + } +} + +// A folder that moved to another filesystem is asked about again: one ask per folder, machine and filesystem, and the +// take records the filesystem it was for. +func TestAFolderThatMovedIsAskedAboutAgain(t *testing.T) { + placed := "fs-000000000001" + r, taken := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies", + placed: placed}) + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + placed = "fs-0000000000ff" + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + var open []asked + for _, a := range takeAsks(r) { + if a.State == askOpen { + open = append(open, a) + } + } + if len(open) != 1 || open[0].Take.Placed != "fs-0000000000ff" || len(takeAsks(r)) != 2 { + t.Fatalf("after the folder moved: %d asks, open %+v", len(takeAsks(r)), open) + } + answerWith(t, r, warrantOn(open[0], "let-the-mesh-keep-it", r.now)) + if len(*taken) != 1 { + t.Fatalf("taken %v", *taken) + } +} + +// The mesh's entries left while a disk was away (kind kept-entries) do not hold a delivery whose move leaves the +// module's accesses as they were — they may name an access no longer declared; one that changes them is held. +func TestEntriesLeftWhileADiskWasAwayDoNotHoldADeliveryThatDidNotBringThem(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries, + Target: moviesAt, State: link.StateUnhealthy, Reason: "the mesh's entries could not be taken out: its disk is away", + Since: since.Add(-time.Hour)} + f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + accessesChanged: map[string]bool{"radarr": false}, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", + HeardAt: now, Resources: []inventory.ResourceHealth{left}}}} + if h, why := moduleHealthWord("radarr", "ace", since, f); h != healthPerson || !strings.Contains(why, "access-gone~entries") { + t.Fatalf("entries left before the send read %v %q; want carried in a passing verdict", h, why) + } + left.Since = now + f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{left}} + if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthPerson { + t.Fatalf("entries stated since the send by a move that leaves accesses as they were read %v", h) + } + f.accessesChanged = nil // a move not known changes them + if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthNotYet { + t.Fatalf("entries of a move not known read %v; want judged unhealthy", h) + } +} + +// An access the engine reports unknown (its filesystem could not be named this look) carries no wait: of a module +// whose move leaves its accesses as they were it is pre-existing and not counted; of one whose move changes them, or +// whose move is not known, it is not yet healthy — held to the gate's bound, never waited on for ever. Entries left +// while a disk was away follow the same rule whenever they were stated. +func TestAnUnknownAccessAndLeftEntriesAreJudgedByWhetherTheMoveChangesAccesses(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + unknown := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-movies", Kind: link.KindAccess, + Target: moviesAt, State: link.StateUnknown, Since: now} + left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries, + Target: moviesAt, State: link.StateUnhealthy, Since: now} + facts := func(changes map[string]bool, rs ...inventory.ResourceHealth) gateFacts { + return gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + accessesChanged: changes, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: rs}}} + } + unchanged := map[string]bool{"radarr": false} + if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, unknown)); h != healthGood { + t.Errorf("an unknown access of a move that leaves accesses as they were reads %v %q; want not counted", h, why) + } + for name, changes := range map[string]map[string]bool{"changes them": {"radarr": true}, "not known": nil} { + if h, _ := moduleHealthWord("radarr", "ace", since, facts(changes, unknown)); h != healthNotYet { + t.Errorf("an unknown access of a move that %s reads %v; want not yet, held to the bound", name, h) + } + } + if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, left)); h != healthPerson || + !strings.Contains(why, "access-gone~entries") { + t.Errorf("entries left, a move that leaves accesses as they were: %v %q; want carried", h, why) + } + if h, _ := moduleHealthWord("radarr", "ace", since, facts(map[string]bool{"radarr": true}, left)); h != healthNotYet { + t.Errorf("entries left since the send by a move that changes accesses read %v", h) + } + if !sameAccesses(radarrManifest.Accesses, []catalogue.Access{{ID: "movies", Path: "/services/media/movies", + Mode: catalogue.AccessReadWrite}}) || sameAccesses(radarrManifest.Accesses, plexManifest.Accesses) { + t.Error("sameAccesses does not tell a changed access from an unchanged one") + } +} + +// A take whose folder's filesystem must first allow access entries says so in the ask (ADR 0325 §8). +func TestTheAskSaysWhenTheTakeEnablesACLs(t *testing.T) { + r, _ := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies", + enablesACL: true}) + }) + if err := r.a.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + q := takeAsks(r)[0].Ask + if !strings.Contains(q.Explanation, "does not allow access entries yet (a ZFS dataset's acltype): approving sets that first") { + t.Fatalf("the ask says %q", q.Explanation) + } + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } +} + +// The engine's access waits are kept by their reason, never by their words, and a kept folder's entries are shown. +func TestTheEnginesAccessWaitsAndEntriesAreKept(t *testing.T) { + lines := healthLines(inventory.NodeHealth{Node: "ace", HeardAt: time.Now(), Resources: []inventory.ResourceHealth{{ + Module: "radarr", Resource: "radarr.access-movies", Kind: link.KindAccess, Target: moviesAt, + State: link.StateHealthy, Entries: []inventory.KeptEntry{{Identity: "mesh-radarr", UID: 50000, Use: "read-write", + From: "container"}, {Identity: "jochen", UID: 1000, Use: "read-write", From: "tool-runner", + Note: "the tool runner's account stands for every module whose tools use this folder"}}}}}, true, time.Now()) + all := strings.Join(lines, "\n") + if !strings.Contains(all, "entry mesh-radarr (uid 50000, read-write, from the container)") || + !strings.Contains(all, "entry jochen (uid 1000, read-write, from the tool-runner) — the tool runner's account") { + t.Fatalf("the listing reads:\n%s", all) + } +} + +// Review of e1c2b744: a warrant for a folder on one filesystem never takes it on another. When the warrant comes and +// the folder no longer waits on the filesystem the ask named, nothing is sent, the ask is spent, and the operator is +// asked again for the filesystem it is on now. +func TestAWarrantForOneFilesystemIsNotCarriedOutOnAnother(t *testing.T) { + placed := "fs-000000000001" + r, taken := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies", + placed: placed}) + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + a := takeAsks(r)[0] + placed = "fs-0000000000ff" // the folder moved before the operator answered + answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now)) + if len(*taken) != 0 { + t.Fatalf("a warrant for %s was carried out on %s: %v", a.Take.Placed, placed, *taken) + } + if spent := r.store[a.ID]; spent.State == askOpen || !strings.HasPrefix(spent.Acted, "nothing") { + t.Fatalf("the ask after the folder moved: %s, %q", spent.State, spent.Acted) + } + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + var again []asked + for _, x := range takeAsks(r) { + if x.State == askOpen { + again = append(again, x) + } + } + if len(again) != 1 || again[0].Take.Placed != placed { + t.Fatalf("not asked again for the filesystem the folder is on now: %+v", again) + } +} + +// Review of e1c2b744: a move that changes who uses an access — a container of the module that starts declaring +// identity — changes the access, so an access the engine cannot judge is not passed uncounted. +func TestAMoveThatChangesAnAccesssUsersChangesTheAccess(t *testing.T) { + before := catalogue.Manifest{Module: "radarr", Accesses: radarrManifest.Accesses, Resources: []map[string]any{ + {"id": "app", "type": "container", "volumes": []any{"${access:movies}:/movies"}}}} + after := catalogue.Manifest{Module: "radarr", Accesses: radarrManifest.Accesses, Resources: []map[string]any{ + {"id": "app", "type": "container", "identity": map[string]any{"user": "run"}, + "volumes": []any{"${access:movies}:/movies"}}}} + if sameAccessUse(before, after) { + t.Fatal("a move that adds identity to the container mounting the access left it the same") + } + if !sameAccessUse(before, before) { + t.Fatal("an unchanged manifest reads changed") + } + // Entries left while a disk was away, stated before the send, of a move that changes the access: counted. + now := time.Now() + since := now.Add(-time.Minute) + left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-movies~entries", Kind: link.KindKeptEntries, + Target: moviesAt, State: link.StateUnhealthy, Since: since.Add(-time.Hour)} + f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + accessesChanged: map[string]bool{"radarr": true}, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", + HeardAt: now, Resources: []inventory.ResourceHealth{left}}}} + if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthNotYet { + t.Fatalf("entries left before the send by a move that changes the access read %v; want counted", h) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 41c027e3..f0216f1d 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -77,6 +77,16 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke for _, w := range r.Waits { kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What}) } + // An access's waits (novox/hq ADR 0325 §7), kept beside the others as one more thing the operator gives: the + // access by its module's id, and whether it waits for a disk rather than for the operator's word. + for _, w := range r.AccessWaits { + kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.Reason == link.WaitDisk, + Placed: w.Placed, Reason: w.Reason, ACL: w.ACL, What: w.What}) + } + for _, e := range r.Entries { + kept.Entries = append(kept.Entries, inventory.KeptEntry{Identity: e.Identity, UID: e.UID, Use: e.Use, From: e.From, + Note: e.Note}) + } resources = append(resources, kept) } // **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose @@ -644,7 +654,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea if waits && !f.groupsAdded[module] { waits = false } - var found, onWaiting []string + var found, onWaiting, leftover []string var forOperator []inventory.Wait for _, r := range resources { if r.Module != module { @@ -667,6 +677,24 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea found = append(found, r.Resource) continue } + // **The mesh's entries left on a folder whose disk was away are a condition this build found** when its move + // leaves the module's accesses and their users as they were (novox/hq ADR 0325 §8): they may name an access no + // longer declared, they wait for the disk, and a delivery that did not bring them is not held for them — said in + // the verdict, as a directory used as found is. A move that changes the accesses is held to them, whenever they + // were stated (the review of e1c2b744). + changed, known := f.accessesChanged[module] + changes := !known || changed + if r.Kind == link.KindKeptEntries && r.State == link.StateUnhealthy && !changes { + leftover = append(leftover, r.Resource) + continue + } + // **An access the engine could not judge this look** (its filesystem could not be named — a hung mount) carries + // no wait. Of a module whose move leaves its accesses as they were it is a condition the move found, not + // counted; of one whose move changes them it is not yet healthy, held to the gate's bound like anything still + // unsettled, so the gate fails safe at the bound and never waits for ever. + if r.Kind == link.KindAccess && r.State == link.StateUnknown && !changes { + continue + } switch r.State { case link.StateHealthy: case link.StateStarting: @@ -690,7 +718,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea reasonAfter(r.Reason)) } } - if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 { + if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 || len(leftover) > 0 { said := onWaiting if waits { said = append(said, wait) @@ -702,6 +730,10 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+ "(`nox node hand-over %s ` on the control-node)", machine, module, strings.Join(found, ", "), machine)) } + if len(leftover) > 0 { + said = append(said, fmt.Sprintf("on %s, the mesh's entries %s could not be taken out while their disk was "+ + "away, stated before this send: they are taken out when it is back", machine, strings.Join(leftover, ", "))) + } return healthPerson, strings.Join(said, "; ") } return healthGood, "" @@ -742,6 +774,14 @@ func healthLines(h inventory.NodeHealth, had bool, now time.Time) []string { line += " (unconfirmed: said once)" } out = append(out, line) + // A kept folder's users, as the engine resolved them (novox/hq ADR 0325 §4), the runner's note beside it. + for _, e := range r.Entries { + entry := fmt.Sprintf(" entry %s (uid %d, %s, from the %s)", e.Identity, e.UID, e.Use, e.From) + if e.Note != "" { + entry += " — " + e.Note + } + out = append(out, entry) + } } return out } diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 9929b2e0..68ec3959 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -487,6 +487,15 @@ func settingsCommand(ctx context.Context, args []string) error { if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil { return err } + // An owner a placement names is warned of (novox/hq ADR 0325 §1): accepted while registered modules still + // name one, refused once they have moved. + if shelf, err := inv.Catalogue(ctx); err == nil { + if m, known := shelf[positionals[0]]; known { + for _, w := range catalogue.PlacesOwnerWarnings(m, []catalogue.Layer{{From: where, Values: values}}) { + fmt.Println(w) + } + } + } fmt.Printf("%s on %s:\n", positionals[0], where) if len(added)+len(changed)+len(removed) == 0 { fmt.Println(" nothing changed") diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index 0357a4ce..0f8f0672 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -44,6 +44,12 @@ func needsOperatorKey(module, node string) string { type operatorWaitFacts struct { manifests map[string]catalogue.Manifest given map[string]map[string]time.Time + // placed is, per "@", where each access of the module is on that machine, by id (novox/hq ADR + // 0325 §7); taken is, per machine, every folder the operator let the mesh keep there, with the filesystem it was + // taken on (empty when the engine named none). A machine absent from taken could not be read, and no access wait + // of it is excused. + placed map[string]map[string]string + taken map[string]map[string]string } // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. @@ -52,9 +58,17 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er if !known { return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module) } + named := 0 + for _, n := range []string{w.Secret, w.Setting, w.Access} { + if n != "" { + named++ + } + } switch { - case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "": - return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w)) + case named != 1: + return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting, or one access", waitNames(w)) + case w.Access != "": + return checkAccessWait(module, machine, m, w, f) case w.Setting != "": if _, declared := m.Settings[w.Setting]; !declared { return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module) @@ -80,6 +94,49 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er return nil } +// checkAccessWait is nil when an access wait is excused (novox/hq ADR 0325 §7): the module's manifest declares the +// access, its folder is placed on that machine, and either the folder is absent and its disk is not there (§9), or +// it has not yet been taken under the operator's warrant (§8). That a folder that is there is not owned by root or a +// root group is the node-engine's to see: it states such an access unhealthy and never waiting (§12). Any other +// access wait is judged unhealthy, saying why: a module that gains an access to a folder already taken is given its +// entries without a wait. +func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.Wait, f operatorWaitFacts) error { + declared := false + for _, a := range m.Accesses { + declared = declared || a.ID == w.Access + } + if !declared { + return fmt.Errorf("says it waits for the access %s, which %s does not declare", w.Access, module) + } + path, placed := f.placed[module+"@"+machine][w.Access] + if !placed { + return fmt.Errorf("says it waits for the access %s, which is not placed on %s", w.Access, machine) + } + switch { + case w.Reason == link.WaitDisk || (w.Reason == "" && w.Disk): + return nil + case w.Reason != link.WaitKeep && w.Reason != "": + return fmt.Errorf("says it waits for the access %s for %q, and an access waits for the operator's word to "+ + "keep its folder or for its disk", w.Access, w.Reason) + case w.ACL == link.ACLUnsupported: + // No act can make this filesystem carry access entries: nothing to ask; the operator places the data elsewhere. + return fmt.Errorf("says it waits for the access %s, whose folder's filesystem cannot carry access entries: "+ + "the operator places the data on one that can, or mounts it with ACLs", w.Access) + } + taken, readable := f.taken[machine] + if !readable { + return fmt.Errorf("says it waits for the access %s, and which folders are taken on %s could not be read", + w.Access, machine) + } + // A folder taken on another filesystem than the one it is on now (it moved to another disk) is not taken by that + // warrant: the wait is excused, and the operator is asked again (ADR 0325 §8). + if on, was := taken[path]; was && (w.Placed == "" || on == w.Placed) { + return fmt.Errorf("says it waits for the access %s, whose folder is taken on %s already: a module that gains "+ + "an access to a taken folder is given its entries without a wait", w.Access, machine) + } + return nil +} + // waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own. const waitRefusedPrefix = "says it waits" @@ -88,6 +145,10 @@ func waitNames(w inventory.Wait) string { switch { case w.Secret != "" && w.Setting != "": return "the secret " + w.Secret + " and the setting " + w.Setting + case w.Access != "" && (w.Secret != "" || w.Setting != ""): + return "the access " + w.Access + " and a secret or a setting" + case w.Access != "": + return "the access " + w.Access case w.Secret != "": return "the secret " + w.Secret case w.Setting != "": @@ -147,6 +208,14 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string { var parts []string for _, w := range waits { part := fmt.Sprintf("%s (%s", w.What, waitNames(w)) + // An access in the controller's own words, never the engine's: words with no path (novox/hq ADR 0325 §7). + switch { + case w.Access != "" && w.Disk: + part = fmt.Sprintf("the disk that holds %s mounted (%s", w.Access, waitNames(w)) + case w.Access != "": + part = fmt.Sprintf("leave to keep the shared folder %s (%s, asked of you once for the folder", w.Access, + waitNames(w)) + } if w.Secret != "" { part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret) } @@ -180,6 +249,11 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words { act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true case w.Setting != "": act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node) + // An access, in words with no path (novox/hq ADR 0325 §7): the folder by the access's name. + case w.Access != "" && w.Disk: + act = fmt.Sprintf("mount the disk that holds %s on %s", w.Access, node) + case w.Access != "": + act = fmt.Sprintf("let the mesh keep the shared folder %s on %s when you are asked", w.Access, node) } if act != "" && !seen[act] { seen[act] = true @@ -217,6 +291,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string if f.given == nil { f.given = map[string]map[string]time.Time{} } + if f.placed == nil { + f.placed = map[string]map[string]string{} + } + if f.taken == nil { + f.taken = map[string]map[string]string{} + } if inv == nil { return } @@ -238,6 +318,20 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string } } } + // Where each access of it is placed there (ADR 0325 §7): read once per module and machine. + if m, known := f.manifests[module]; known && len(m.Accesses) > 0 { + if _, read := f.placed[module+"@"+machine]; !read { + if layers, err := inv.SettingsFor(ctx, machine, module); err == nil { + placed := map[string]string{} + for _, a := range m.Accesses { + if path, has := catalogue.AccessPathOn(m, layers, a.ID); has && a.ID != "" { + placed[a.ID] = path + } + } + f.placed[module+"@"+machine] = placed + } + } + } if _, read := f.given[module+"@"+machine]; read { continue } @@ -245,6 +339,15 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string f.given[module+"@"+machine] = given } } + if _, read := f.taken[machine]; !read { + if taken, err := inv.KeptFolders(ctx, machine); err == nil { + kept := map[string]string{} + for path, t := range taken { + kept[path] = t.Placed + } + f.taken[machine] = kept + } + } } // waitingModules is every module with a waiting resource in a statement. diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 3a6a0a50..7a84b392 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "sort" "strings" @@ -447,7 +448,7 @@ func declarationWith(ctx context.Context, open *stores, node string, if err != nil { return sendable{}, err } - out := sendable{Resources: composed.Resources, Adoption: adoption, + out := sendable{Resources: composed.Resources, Adoption: adoption, Identities: composed.Identities, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld, unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced, @@ -975,9 +976,18 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + // Each module's identity and the folders taken here (novox/hq ADR 0325 §3, §8), to an engine that keeps folders. + keepsFolders, identities, keptFolders, readyFolders, err := folderFacts(ctx, inv, node, plan, choosing) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } return catalogue.Rendering{ ReadsHealth: readsHealth, JudgesRoot: judgesRoot, + KeepsFolders: keepsFolders, + Identities: identities, + KeptFolders: keptFolders, + ReadyFolders: readyFolders, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, @@ -1010,6 +1020,79 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string return had && stated.Contract >= link.RootContract, nil } +// engineKeepsFolders says whether a machine's node-engine gives modules their identities and keeps shared folders +// (novox/hq ADR 0325), by its own newest statement, for the same reason as engineReadsHealth: an older engine parses +// strictly, and is sent none of it. +func engineKeepsFolders(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) { + stated, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false, err + } + return had && stated.Contract >= link.IdentityContract, nil +} + +// folderFacts is what a machine's composition needs of ADR 0325: whether its engine keeps folders, each identity of +// a module of it that declares one, and the folders taken there. **An identity is allocated only on the send path** +// (Allocating), as a port is: a question about what would be sent reads what was allocated, and a module that has +// none yet is composed without one. Nothing is read for an engine that does not keep folders. +func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution, + choosing Choosing) (bool, map[string]catalogue.ModuleIdentity, map[string]bool, map[string]bool, error) { + keeps, err := engineKeepsFolders(ctx, inv, node) + if err != nil || !keeps { + return false, nil, nil, nil, err + } + identities := map[string]catalogue.ModuleIdentity{} + for _, m := range plan.Modules { + if !catalogue.DeclaresIdentity(m) { + continue + } + var id catalogue.ModuleIdentity + had := true + if choosing == Allocating { + id, err = inv.AllocateIdentity(ctx, m.Module) + } else { + id, had, err = inv.IdentityOf(ctx, m.Module) + } + if err != nil { + return false, nil, nil, nil, err + } + if had { + identities[m.Module] = id + } + } + taken, err := inv.KeptFolders(ctx, node) + if err != nil { + return false, nil, nil, nil, err + } + kept := map[string]bool{} + for path := range taken { + kept[path] = true + } + // **A folder that moved to another filesystem is not taken there until its new warrant** (ADR 0325 §8): the + // machine's newest statement names, without a path, the filesystem each folder waiting for the operator is on now. + // And every folder the engine says it keeps (its access healthy): one it found empty, or took itself — what, with + // a taken one, lets a container switch to its identity (identityHeld). + ready := map[string]bool{} + if stated, had, err := inv.HealthOf(ctx, node); err != nil { + return false, nil, nil, nil, err + } else if had { + for _, r := range stated.Resources { + if r.Kind == link.KindAccess && r.State == link.StateHealthy && r.Target != "" { + ready[filepath.Clean(r.Target)] = true + } + } + for _, r := range stated.Resources { + for _, w := range r.Waits { + path := filepath.Clean(r.Target) + if t, was := taken[path]; was && w.Access != "" && !w.Disk && w.Placed != "" && w.Placed != t.Placed { + kept[path] = false + } + } + } + } + return true, identities, kept, ready, nil +} + // zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR // 0199): the zone settled from that node's settings, the node's private address, the port the // answering listen is published on there. diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 0c1e2116..20385898 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -18,6 +18,9 @@ import ( // make a machine look out of date for ever, or send something `plan` never showed. type sendable struct { Resources []map[string]any + // Identities is the declaration's `identities` (novox/hq ADR 0325 §3): composed only for an engine that keeps + // folders, and not sent at all when empty — an older engine refuses a key it does not know, whole. + Identities []map[string]any // Sequence orders this send against every other to the same node: one higher each time, taken // under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent // at all, which a host reads as "no order claimed" — the shape of every declaration before this. @@ -110,6 +113,9 @@ func (s sendable) Body() ([]byte, error) { if s.Generation > 0 { envelope["generation"] = s.Generation } + if len(s.Identities) > 0 { + envelope["identities"] = s.Identities + } // An empty declaration is deliberate here — the node owns nothing the mesh put there // (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness // is meant, so a truncated or mis-composed body is never mistaken for "own nothing". diff --git a/go.mod b/go.mod index 60ad6d19..ed14f26c 100644 --- a/go.mod +++ b/go.mod @@ -36,4 +36,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e diff --git a/go.sum b/go.sum index 96650c70..5c037bcc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU= -git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= +git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e h1:RxvY7T12EizGs2e9W1dVZFEnQFJBPNKRY+oUPgovcBc= +git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 3e5a42ca..eac4c380 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -343,6 +343,11 @@ func AskJudgeDirectoriesSubject(node string) string { return "mesh.node." + node + ".ask.judge-directories" } +// AskAccessActSubject is where the controller asks one machine's node-engine to take a shared folder on the +// operator's warrant (novox/hq ADR 0325 §8; mesh-host internal/link AskAccessActSubject): a request answered once, +// signed as a hand-over is (link.AccessActContext), for the same reason. +func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" } + // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // inbox is derived from its own identity and its permissions name that prefix and no other. @@ -623,7 +628,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. AskSetuidSearchSubject(p.Node), // And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way. - AskJudgeDirectoriesSubject(p.Node)} + AskJudgeDirectoriesSubject(p.Node), + // And asking it to take a shared folder on the operator's warrant (novox/hq ADR 0325 §8), answered the same way. + AskAccessActSubject(p.Node)} // The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the // contract is lease/witness.go): it asks its own machine's node tools PING, and, where the // machine runs the controller, reads the lease's one key — read, never written. diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8270aaf3..068deb45 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -34,7 +34,7 @@ accounts { } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] } - subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } + subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 6099dfb0..023db5bd 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -101,6 +101,11 @@ var WritersTable = []WriterRow{ {State: "a judgement of a machine's directories asked of it", Writer: "controller", KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses", Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController}, + // The take of a shared folder asked of a machine's engine on the operator's warrant (novox/hq ADR 0325 §8): + // signed as a hand-over is, under a signing context of its own. + {State: "the take of a shared folder asked of a machine", Writer: "controller, on the operator's warrant", + KeptIn: "the machine, which records the act", Others: "the engine verifies the mesh's signature and takes it, or refuses", + Subjects: []string{"mesh.node.*.ask.access-act"}, Writes: isController}, {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 124de422..a3725b94 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -18,6 +18,7 @@ var designRows = []string{ "a hand-over asked of a machine", "a fresh setuid search asked of a machine", "a judgement of a machine's directories asked of it", + "the take of a shared folder asked of a machine", "a machine's applied state and its report", "the controller lease", "plans and their tiers", diff --git a/internal/builder/check.go b/internal/builder/check.go index 0a08d40c..25ce2060 100644 --- a/internal/builder/check.go +++ b/internal/builder/check.go @@ -939,14 +939,16 @@ func replaceGoFiles(from, into string) error { } // problemLines are the lines of a module check's output that name a problem: not a manifest found ok, not -// the count, not the closing note. +// the count, not the closing note, not a warning. func problemLines(s string) []string { var out []string for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) switch { case line == "", strings.Contains(line, ": ok"), strings.Contains(line, "problem(s) in"), - strings.Contains(line, "manifest(s) checked"): + strings.Contains(line, "manifest(s) checked"), + // A warning is said and fails nothing (novox/hq ADR 0325: what is refused once the catalogues moved). + strings.HasPrefix(line, "warning: "): continue } out = append(out, line) diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 4cbdde6b..abe22e32 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -246,6 +246,20 @@ type Rendering struct { // statement's contract is link.RootContract or later). To an older, strict engine the field is not // sent, and the account it names is not judged — which the self-check says, as not judged. JudgesRoot bool + + // KeepsFolders says this machine's node-engine gives modules their identities and keeps shared folders + // (novox/hq ADR 0325: its statement's contract is link.IdentityContract or later). To an older, strict engine none of + // it is sent — no `identities`, no container `identity`, no access `users` or `taken`, no owner derived from an + // identity — and what it runs is composed as before. + KeepsFolders bool + // Identities is each module's allocated identity, by module (ADR 0325 §3): the same on every machine. A module + // that declares `identity` and has none here is composed without one, as to an older engine. + Identities map[string]ModuleIdentity + // KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path. + KeptFolders map[string]bool + // ReadyFolders is every folder the engine states it keeps on this machine (its access healthy), by clean path: a + // folder it found empty and kept, or took itself. With KeptFolders, what lets a container switch to its identity. + ReadyFolders map[string]bool } // RootField is a user resource's field saying the account must never become root without a person @@ -320,6 +334,9 @@ type Composed struct { // naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told // everything else. Push and plan say it; the merge gate refuses a change that adds one. Unplaced []string + // Identities is the declaration's `identities` (novox/hq ADR 0325 §3): [{module, name, uid, gid}] for each + // module composed with an identity. Empty to an engine that does not keep folders. + Identities []map[string]any } // ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of @@ -358,7 +375,9 @@ func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string out[m.Module] = &UnreadManifestError{Module: m.Module, said: why} continue } - if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { + // A placement stored ahead of the build that declares its access is passed by, not a reason to leave the + // module out (novox/hq issue 575): it was judged against that build where it was stored. + if err := ComposesWith(m, settings[m.Module], adopted); err != nil { out[m.Module] = err } } @@ -380,7 +399,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { received := map[string]map[string][]Contribution{} leftOut := map[string]string{} var foreseen, unplaced []string - resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced) + var identities []ModuleIdentity + resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities) if err != nil { return Composed{}, err } @@ -394,8 +414,12 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { }) } sort.Strings(unplaced) - return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, - Foreseen: foreseen, Unplaced: unplaced}, nil + composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, + Foreseen: foreseen, Unplaced: unplaced} + if len(identities) > 0 { + composed.Identities = identitiesOf(identities) + } + return composed, nil } // BusMembershipID names the resource carrying a machine's membership for the new bus, and @@ -406,7 +430,7 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json" func (r Resolution) compose(with Rendering, owner map[string]string, received map[string]map[string][]Contribution, leftOut map[string]string, - foreseen, unplaced *[]string) ([]map[string]any, error) { + foreseen, unplaced *[]string, identities *[]ModuleIdentity) ([]map[string]any, error) { // **A setting is judged where it is stored, and an impossible one costs a module, not a // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes // this module uncomposable; it is left out of the declaration — its held things kept, its @@ -553,6 +577,19 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } resources := m.Resources + // The identity it runs as here (novox/hq ADR 0325 §3, §11): none to an older engine, nor without an allocation. + ident, hasIdent, err := identityFor(m, with) + if err != nil { + return nil, err + } + if hasIdent && identities != nil { + *identities = append(*identities, ident) + } + mountedByIdentity := dirsMountedByIdentity(m) + // Its containers run as it only once every read-write folder they mount is taken (the operator's question on + // ADR 0325): until then the identity is allocated, sent and named in the take's users, and not switched to. + runsAsIdent := hasIdent && !identityHeld(m, with) + // What the mesh computes for this module goes FIRST, before the module's own resources. // // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so @@ -1003,6 +1040,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, return nil, err } ownerInto(copied, placed) + // And its identity (novox/hq ADR 0325 §2, §3): a container's resolved, or taken out where none is composed; + // the owner of a directory its identity's containers mount. After the placement's owner: the identity wins. + if err := identityInto(copied, m, ident, runsAsIdent, mountedByIdentity); err != nil { + return nil, err + } // **After settings, and that is the whole reason it is here.** A module's file // content is where a setting lands, so a placeholder may only exist once the setting // has been put in — filling secrets first would look at content that is not yet what @@ -1120,6 +1162,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string, out = append(out, copied) } + // **Who uses each of its shared folders** (novox/hq ADR 0325 §4, §5, §8): the access's `users` and `taken`, to + // an engine that keeps folders. + if with.KeepsFolders { + usersInto(out, owner, m, ident, hasIdent, r.toolsAccount(m, with), with) + } + // **What only the mesh knows, where this module asked for it.** The graph is the control // plane's; making a name resolve is the module's software. Emitted as ordinary files under // this module's name, so they are applied, reported and removed exactly as anything else diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 024d850a..c6e3717d 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1860,6 +1860,8 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) + // How each container's image is told who to run as, and what a module that says so is held to (novox/hq ADR 0325). + problems = append(problems, identityProblems(m)...) problems = append(problems, endpointNameProblems(m)...) problems = append(problems, RouteProblems(m)...) for _, port := range m.Guards { diff --git a/internal/catalogue/module_identity.go b/internal/catalogue/module_identity.go new file mode 100644 index 00000000..c715fa2c --- /dev/null +++ b/internal/catalogue/module_identity.go @@ -0,0 +1,575 @@ +package catalogue + +import ( + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Every module is given an identity of its own, and a container that declares `identity` runs as it (novox/hq ADR +// 0325 §2, §3, §11). +// +// **The manifest says how its image is told who to run as, and nothing else.** A container resource may declare +// `identity`: `{"user": "env:PUID", "group": "env:PGID"}` for an image that reads its user and group from its +// environment, or `{"user": "run"}` for the container runtime's own user option. It is the image's interface, as its +// ports are. The mesh fills it in: the controller allocates the module one user and group number, mesh-wide, once, +// from a range of its own (IdentityLow–IdentityHigh), and the node-engine makes the account `mesh-` on each +// machine the module is assigned to and tells the image those numbers. +// +// **The range is 50000–59999, and a module's user and group numbers are one number.** Chosen above the numbers +// distributions give people (1000 up) and below the 60000s Debian keeps for its own daemons, and far from root's 0 +// and every root group a distribution ships (0, adm's 4, wheel's 10): no number in it is 0 or a root group's, so §11 +// holds by the range alone, and the store refuses a row outside it. Both numbers come from one sequence, so a +// module's uid and gid are equal; a number once given is never given again, even after the module is forgotten +// (the store keeps the row, inventory.AllocateIdentity). +// +// **What the controller sends, and only to an engine that reads it** (Rendering.KeepsFolders: its statement's +// contract is link.IdentityContract or later): +// +// - the declaration's `identities`: [{module, name, uid, gid}] for each module of the machine that declares +// `identity` on a container; +// - a container's `identity` resolved: {"mode": "env", "user_env", "group_env", "uid", "gid"} or {"mode": "run", +// "uid", "gid"}; +// - the owner of each of the module's directories a container declaring `identity` mounts: the identity's +// uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the +// identity, and owning it by the identity would lock its writer out; +// - an access's `users`: the module's identity where a container of it that declares `identity` mounts the +// access, and the tool runner's account where the module's tools run there as named, its number the engine reads by name, +// each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access +// to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`, +// whether the operator let the mesh keep that folder on that machine (§8). +// +// To an older engine none of it is sent, and a container's `identity` is taken out: it runs as its image says, as +// before. A module that declares no `identity` is composed exactly as before, on any engine. + +// The range module identities are allocated from (novox/hq ADR 0325 §3, §11). The store holds the same bounds. +const ( + IdentityLow = 50000 + IdentityHigh = 59999 +) + +// AllocatableID says whether n is a number the mesh may give a module's identity: inside the range, so never 0 +// nor a root group's. +func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh } + +// IdentityField is a container resource's field saying how its image is told who to run as. +const IdentityField = "identity" + +// The two ways an image is told who to run as. +const ( + IdentityEnv = "env" + IdentityRun = "run" +) + +// IdentitySpec is a container's `identity` as its manifest declares it. +type IdentitySpec struct { + // Mode is IdentityEnv or IdentityRun. + Mode string + // UserEnv and GroupEnv are the environment variables an env image reads, such as PUID and PGID. + UserEnv, GroupEnv string +} + +// ModuleIdentity is one module's allocated identity: its numbers, the same on every machine. +type ModuleIdentity struct { + Module string + UID int + GID int +} + +// IdentityName is the account a module's identity is on every machine it is assigned to. +func IdentityName(module string) string { return "mesh-" + module } + +// Where an access's user comes from (novox/hq ADR 0325 §4, §5). +const ( + FromContainer = "container" + FromToolRunner = "tool-runner" +) + +// AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration +// AccessUser): the account, its user number, the use, and where it runs. The tool runner's account is named with no +// number: it is the machine's, and the node-engine resolves it by name, refusing one missing or root's. +type AccessUser struct { + Identity string `json:"identity"` + UID int `json:"uid,omitempty"` + Use string `json:"use"` + From string `json:"from"` +} + +var envName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +// ParseIdentity reads a container's `identity`, or refuses it in words. +func ParseIdentity(v any) (IdentitySpec, error) { + fields, ok := v.(map[string]any) + if !ok { + return IdentitySpec{}, fmt.Errorf("identity is {\"user\": \"env:\", \"group\": \"env:\"} or " + + "{\"user\": \"run\"}") + } + for k := range fields { + if k != "user" && k != "group" { + return IdentitySpec{}, fmt.Errorf("identity says %q, and it says only how the image is told its user "+ + "and group: \"user\" and \"group\", never an owner or a number (%s §1, §2)", k, ADR0325) + } + } + user, _ := fields["user"].(string) + group, hasGroup := fields["group"] + switch { + case user == IdentityRun: + if hasGroup { + return IdentitySpec{}, fmt.Errorf("identity {\"user\": \"run\"} takes no group: the runtime's user " + + "option carries both numbers") + } + return IdentitySpec{Mode: IdentityRun}, nil + case strings.HasPrefix(user, "env:"): + g, _ := group.(string) + if !strings.HasPrefix(g, "env:") { + return IdentitySpec{}, fmt.Errorf("identity names its user as %q and its group as %v; an image told "+ + "its user by its environment is told its group the same way, \"env:\"", user, group) + } + u, gr := strings.TrimPrefix(user, "env:"), strings.TrimPrefix(g, "env:") + if !envName.MatchString(u) || !envName.MatchString(gr) || u == gr { + return IdentitySpec{}, fmt.Errorf("identity names the variables %q and %q; each is an environment "+ + "variable's name, upper-case letters, digits and underscores, and the two differ", u, gr) + } + return IdentitySpec{Mode: IdentityEnv, UserEnv: u, GroupEnv: gr}, nil + } + return IdentitySpec{}, fmt.Errorf("identity says its user is %q; it is \"env:\" or \"run\"", user) +} + +// DeclaresIdentity says a module has a container that declares `identity`: it is given one (§3). +func DeclaresIdentity(m Manifest) bool { + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "container" { + if _, has := r[IdentityField]; has { + return true + } + } + } + return false +} + +// containerMounts is what a container of the manifest mounts, read from its volumes as written: the module's own +// directories by id, and its accesses by id — or, for a literal source, as "path:". +func containerMounts(r map[string]any) (dirs, accesses []string) { + volumes, _ := r["volumes"].([]any) + for _, v := range volumes { + s, _ := v.(string) + source := volumeSource(s) + if strings.HasPrefix(source, "/") { + accesses = append(accesses, "path:"+filepath.Clean(source)) + continue + } + for _, m := range dirRef.FindAllStringSubmatch(source, -1) { + dirs = append(dirs, m[1]) + } + for _, m := range accessRef.FindAllStringSubmatch(source, -1) { + accesses = append(accesses, m[1]) + } + } + return dirs, accesses +} + +// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so +// "${dir:config}:/config" is "${dir:config}". +func volumeSource(v string) string { + depth := 0 + for i, r := range v { + switch { + case r == '{' && i > 0 && v[i-1] == '$': + depth++ + case r == '}' && depth > 0: + depth-- + case r == ':' && depth == 0: + return v[:i] + } + } + return v +} + +// AccessUsersShape is who among a module's containers uses which access as the module's identity, in a form that +// compares: each container declaring identity with how it is told, and the accesses it mounts, sorted. Two manifests +// with the same shape give their accesses the same container users. +func AccessUsersShape(m Manifest) string { + var out []string + for _, r := range m.Resources { + v, declares := r[IdentityField] + if fmt.Sprint(r["type"]) != "container" || !declares { + continue + } + _, accesses := containerMounts(r) + sort.Strings(accesses) + out = append(out, fmt.Sprint(r["id"], v, accesses)) + } + sort.Strings(out) + return strings.Join(out, "\x01") +} + +// accessMode is the mode an access a container mounts was declared at, by id or by "path:"; "" when the +// manifest declares no such access. +func accessMode(m Manifest, ref string) string { + for _, a := range m.Accesses { + if (a.ID != "" && a.ID == ref) || (a.Path != "" && ref == "path:"+filepath.Clean(a.Path)) { + return a.At() + } + } + return "" +} + +// ADR0325 is how the module check names the decision its identity rules come from. +const ADR0325 = "novox/hq ADR 0325" + +// identityProblems is what the module check refuses of a manifest's identities (novox/hq ADR 0325 §1, §2, §3): +// +// - an `identity` on anything but a container, or one that is neither env: for both nor run; +// - **in a module that declares `identity` on a container** — it says it runs as its own identity, so it is held +// to the decision whole: a directory of it naming an owner (§1, §3), and a container of it that declares none +// and mounts one of the module's directories or a read-write access (§2). +// +// A module that declares no `identity` is warned of the last two (IdentityWarnings), never refused for them yet: +// every registered container module mounts its own directories without one, and refusing them would stop the +// catalogues composing before they can migrate. **This is the narrowest rule that breaks nothing registered**: no +// registered manifest declares `identity`, so no registered module is refused anything new. +func identityProblems(m Manifest) []string { + var problems []string + for _, r := range m.Resources { + v, has := r[IdentityField] + if !has { + continue + } + if fmt.Sprint(r["type"]) != "container" { + problems = append(problems, fmt.Sprintf("%s: %v is a %v and says identity; only a container says how its "+ + "image is told who to run as (%s §2)", m.Module, r["id"], r["type"], ADR0325)) + continue + } + if _, err := ParseIdentity(v); err != nil { + problems = append(problems, fmt.Sprintf("%s: the container %v: %v (%s §2)", m.Module, r["id"], err, ADR0325)) + } + } + if !DeclaresIdentity(m) { + return problems + } + return append(problems, identityRules(m)...) +} + +// IdentityWarnings is what the module check warns of a module that declares no `identity` (novox/hq ADR 0325): each +// thing the decision refuses once the module migrates, said now so the catalogues can. Refusing comes when no +// registered module still does any of it. Empty for a module that declares `identity`: it is refused these instead. +func IdentityWarnings(m Manifest) []string { + if DeclaresIdentity(m) { + return nil + } + var out []string + for _, p := range identityRules(m) { + out = append(out, p+"; accepted while registered modules still do it, refused once they have moved") + } + return out +} + +// identityRules is every place a manifest breaks what ADR 0325 §1–§3 refuse: a directory naming an owner, and a +// container declaring no identity that mounts the module's own directory or a read-write access. +func identityRules(m Manifest) []string { + var out []string + for _, r := range m.Resources { + switch fmt.Sprint(r["type"]) { + case "directory": + if owner, said := r["owner"]; said { + out = append(out, fmt.Sprintf("%s: the directory %v names its owner %v; a directory a module owns is "+ + "owned by the module's identity, and no manifest names an owner (%s §1, §3)", m.Module, r["id"], + owner, ADR0325)) + } + case "container": + if _, has := r[IdentityField]; has { + continue + } + dirs, accesses := containerMounts(r) + seen := map[string]bool{} + for _, d := range dirs { + if !seen[d] { + seen[d] = true + out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the module's "+ + "own directory %s; only a container that declares identity mounts its module's directories, "+ + "so what writes them is the module's identity (%s §2)", m.Module, r["id"], d, ADR0325)) + } + } + for _, a := range accesses { + if accessMode(m, a) == AccessReadWrite && !seen["access:"+a] { + seen["access:"+a] = true + out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the read-write "+ + "access %s; a container that runs as its image says is given no entry on a shared folder, so "+ + "it is refused write (%s §2)", m.Module, r["id"], strings.TrimPrefix(a, "path:"), ADR0325)) + } + } + } + } + return out +} + +// PlacesOwnerProblem refuses an owner a `places` setting names for a module that declares `identity` (novox/hq ADR +// 0325 §1, §3): its directories are owned by its identity. Nil for a module that declares none, whose placed owner +// is still applied, and warned of by PlacesOwnerWarnings. +func PlacesOwnerProblem(m Manifest, layers []Layer) error { + if !DeclaresIdentity(m) { + return nil + } + if owned := placedOwners(m, layers); len(owned) > 0 { + return fmt.Errorf("%s places %s with an owner, and declares identity: its directories are owned by its "+ + "identity, and no setting names an owner (%s §1, §3)", m.Module, strings.Join(owned, ", "), ADR0325) + } + return nil +} + +// PlacesOwnerWarnings is a warning for each directory a `places` setting gives an owner, for a module that declares +// no `identity` (novox/hq ADR 0325 §1): accepted while registered modules still do it, refused once they have moved. +func PlacesOwnerWarnings(m Manifest, layers []Layer) []string { + if DeclaresIdentity(m) { + return nil + } + var out []string + for _, id := range placedOwners(m, layers) { + out = append(out, fmt.Sprintf("warning: %s's setting places.%s.owner names an owner; a directory a module owns "+ + "is owned by the module's identity, and no setting names an owner (%s §1, §3). Accepted while "+ + "registered modules still do it, refused once they have moved", m.Module, id, ADR0325)) + } + return out +} + +func placedOwners(m Manifest, layers []Layer) []string { + placed, err := Places(m, layers) + if err != nil { + // empty-on-error: a placement Places refuses is refused by name where it is judged and composed; it names no owner here + return nil + } + var out []string + for id, p := range placed { + if p.Owner != "" { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +// resolvedIdentity is a container's `identity` as the node-engine is told it: the mode, the variables an env image +// reads, and the module's numbers. +func resolvedIdentity(spec IdentitySpec, id ModuleIdentity) map[string]any { + out := map[string]any{"mode": spec.Mode, "uid": id.UID, "gid": id.GID} + if spec.Mode == IdentityEnv { + out["user_env"], out["group_env"] = spec.UserEnv, spec.GroupEnv + } + return out +} + +// identityFor is the identity module m runs as here, and whether one is composed: a module that declares `identity`, +// to an engine that keeps folders, with an allocation the composition was given. A number outside the range is +// refused (§11), never sent. +func identityFor(m Manifest, with Rendering) (ModuleIdentity, bool, error) { + if !with.KeepsFolders || !DeclaresIdentity(m) { + return ModuleIdentity{}, false, nil + } + id, allocated := with.Identities[m.Module] + if !allocated { + return ModuleIdentity{}, false, nil + } + if !AllocatableID(id.UID) || !AllocatableID(id.GID) { + return ModuleIdentity{}, false, fmt.Errorf("%s's identity is %d:%d, outside %d–%d: no module identity is root "+ + "or a root group's, and none is sent (%s §11)", m.Module, id.UID, id.GID, IdentityLow, IdentityHigh, ADR0325) + } + id.Module = m.Module + return id, true, nil +} + +// identityInto composes one resource of module m for its identity: a container's `identity` resolved, or taken out +// where none is composed; and the owner of a directory one of its identity's containers mounts. +func identityInto(resource map[string]any, m Manifest, id ModuleIdentity, has bool, mountedByIdentity map[string]bool) error { + switch fmt.Sprint(resource["type"]) { + case "container": + v, declared := resource[IdentityField] + if !declared { + return nil + } + if !has { + delete(resource, IdentityField) + return nil + } + spec, err := ParseIdentity(v) + if err != nil { + return fmt.Errorf("%s: the container %v: %w", m.Module, resource["id"], err) + } + resource[IdentityField] = resolvedIdentity(spec, id) + case "directory": + if has && mountedByIdentity[fmt.Sprint(resource["id"])] { + resource["owner"] = fmt.Sprintf("%d:%d", id.UID, id.GID) + } + } + return nil +} + +// identityHeld says module m's containers are not switched to its identity yet (the operator's question on ADR 0325): +// a container declaring `identity` mounts a read-write access whose folder is neither taken (KeptFolders) nor kept by +// the engine (ReadyFolders). Switched before the take adds the identity's entry, the app would run as the new uid and +// lose write on the operator's existing files; held, it runs as it did (its environment's PUID and PGID), the take's +// users already name the identity, and the switch comes in the apply after every such folder is taken. +func identityHeld(m Manifest, with Rendering) bool { + _, byID, err := accessesFor(m, with.Settings[m.Module]) + if err != nil { + return true // refused where the module itself is composed; never switched on a guess + } + for _, r := range m.Resources { + if _, declares := r[IdentityField]; !declares || fmt.Sprint(r["type"]) != "container" { + continue + } + _, refs := containerMounts(r) + for _, ref := range refs { + if accessMode(m, ref) != AccessReadWrite { + continue + } + path := byID[ref] + if strings.HasPrefix(ref, "path:") { + path = strings.TrimPrefix(ref, "path:") + } + path = filepath.Clean(path) + if path != "." && !with.KeptFolders[path] && !with.ReadyFolders[path] { + return true + } + } + } + return false +} + +// identityContainers is the composed ids of module m's containers that declare `identity`, whether or not it is sent. +func identityContainers(m Manifest) map[string]bool { + out := map[string]bool{} + for _, r := range m.Resources { + if _, declares := r[IdentityField]; declares && fmt.Sprint(r["type"]) == "container" { + out[m.Module+"."+fmt.Sprint(r["id"])] = true + } + } + return out +} + +// dirsMountedByIdentity is the ids of module m's directories one of its containers declaring `identity` mounts. +func dirsMountedByIdentity(m Manifest) map[string]bool { + out := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + if _, has := r[IdentityField]; !has { + continue + } + dirs, _ := containerMounts(r) + for _, d := range dirs { + out[d] = true + } + } + return out +} + +// identitiesOf is the declaration's `identities`, by module. +func identitiesOf(ids []ModuleIdentity) []map[string]any { + sort.Slice(ids, func(i, j int) bool { return ids[i].Module < ids[j].Module }) + out := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"module": id.Module, "name": IdentityName(id.Module), "uid": id.UID, + "gid": id.GID}) + } + return out +} + +// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no +// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration. +func (r Resolution) toolsAccount(m Manifest, with Rendering) string { + if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) { + return "" + } + loads := false + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + loads = true + } + } + if !loads { + return "" + } + if m.RunsAs != "" { + return m.RunsAs + } + return r.Account +} + +// usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity +// where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the +// module's tools run as it, each at the access's use (§4: the engine keeps the widest). +func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string, + with Rendering) { + // The containers that declare identity, whether or not it is sent yet: the take adds the entries of the identity + // to come, so they exist before the switch. + declaring := identityContainers(m) + var containers []map[string]any + for _, res := range out { + if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module && declaring[fmt.Sprint(res["id"])] && has { + containers = append(containers, res) + } + } + for _, res := range out { + if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module { + continue + } + path, _ := res["path"].(string) + mode, _ := res["mode"].(string) + if mode == "" { + mode = AccessRead + } + users := []AccessUser{} + for _, c := range containers { + if mounts(c, path) { + users = append(users, AccessUser{Identity: IdentityName(m.Module), UID: id.UID, Use: mode, From: FromContainer}) + break // one identity per module, whichever of its containers mounts it + } + } + if account != "" { + users = append(users, AccessUser{Identity: account, Use: mode, From: FromToolRunner}) + } + if len(users) > 0 { + res["users"] = users + } + res["taken"] = with.KeptFolders[filepath.Clean(path)] + } +} + +// mounts says whether a composed container mounts path, or something below it, from the machine. A path that merely +// begins with the same letters — /storage/media2 beside /storage/media — is another path. +func mounts(container map[string]any, path string) bool { + volumes, _ := container["volumes"].([]any) + path = filepath.Clean(path) + for _, v := range volumes { + s, ok := v.(string) + if !ok { + continue + } + source, _, _ := strings.Cut(s, ":") + if !strings.HasPrefix(source, "/") { + continue // a named volume: the runtime's, not the operator's path + } + source = filepath.Clean(source) + if source == path || strings.HasPrefix(source, path+"/") { + return true + } + } + return false +} + +// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the +// definition's default; false where neither says, or the module declares no such access. +func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) { + _, byID, err := accessesFor(m, layers) + if err != nil { + return "", false + } + path, has := byID[id] + if !has || path == "" { + return "", false + } + return filepath.Clean(path), true +} diff --git a/internal/catalogue/module_identity_test.go b/internal/catalogue/module_identity_test.go new file mode 100644 index 00000000..0ded5053 --- /dev/null +++ b/internal/catalogue/module_identity_test.go @@ -0,0 +1,370 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + + "github.com/novox/mesh-host/validate" +) + +// Defends novox/hq ADR 0325 on the controller's side, row by row of its "How it is checked" table: the module check +// (rows 1, 2), the identity composed (row 3), the tool runner's entry (row 4), no identity root (row 11) — and none +// of it sent to a node-engine older than the contract, which would refuse the whole declaration. + +const identityImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111" + +// aLibrarian files films: a container told its identity by PUID and PGID that mounts its config and the movies it +// writes; a sidecar that declares none and reads the music; a directory only its tools write; and tools served by the +// machine's runtime. +func aLibrarian(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "librarian", "tools/index.js") + m.Accesses = []Access{ + {ID: "movies", Path: "/storage/media/movies", Mode: AccessReadWrite}, + {ID: "music", Path: "/storage/media/music", Mode: AccessRead}, + } + m.Resources = []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "notes", "type": "directory", "mode": "0700"}, + {"id": "app", "type": "container", "name": "librarian", "image": identityImage, + "identity": map[string]any{"user": "env:PUID", "group": "env:PGID"}, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}, + {"id": "sidecar", "type": "container", "name": "librarian-sidecar", "image": identityImage, + "volumes": []any{"${access:music}:/music:ro"}}, + } + return m +} + +// aPlayer reads the movies the librarian writes, as its own identity through the runtime's user option, and has +// tools of its own that read them too. +func aPlayer(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "player", "tools/index.js") + m.Accesses = []Access{{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}} + m.Resources = []map[string]any{ + {"id": "app", "type": "container", "name": "player", "image": identityImage, + "identity": map[string]any{"user": "run"}, "volumes": []any{"${access:movies}:/movies:ro"}}, + } + return m +} + +func keepingFolders(ids map[string]ModuleIdentity, kept ...string) Rendering { + with := Rendering{KeepsFolders: true, Identities: ids, KeptFolders: map[string]bool{}} + for _, k := range kept { + with.KeptFolders[k] = true + } + return with +} + +func composeFolders(t *testing.T, r Resolution, with Rendering) Composed { + t.Helper() + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + composed, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + return composed +} + +var identities = map[string]ModuleIdentity{"librarian": {UID: 50001, GID: 50001}, "player": {UID: 50002, GID: 50002}} + +// Row 3: the identity composed — the same on every machine, the container told it, the module's directories its +// containers mount owned by it, and the declaration's identities naming the account. +func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) { + var seen []string + for _, node := range []string{"ace", "novox"} { + r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + composed := composeFolders(t, r, keepingFolders(identities, "/storage/media/movies")) // its read-write folder taken: switched + got := byID(composed.Resources) + + app := got["librarian.app"] + ident, _ := app[IdentityField].(map[string]any) + want := map[string]any{"mode": "env", "user_env": "PUID", "group_env": "PGID", "uid": 50001, "gid": 50001} + if fmt.Sprint(ident) != fmt.Sprint(want) { + t.Errorf("%s: the container is told %v; want %v", node, ident, want) + } + player, _ := got["player.app"][IdentityField].(map[string]any) + if fmt.Sprint(player) != fmt.Sprint(map[string]any{"mode": "run", "uid": 50002, "gid": 50002}) { + t.Errorf("%s: the player is told %v", node, player) + } + if _, has := got["librarian.sidecar"][IdentityField]; has { + t.Errorf("%s: a container declaring no identity was given one", node) + } + if owner := got["librarian.config"]["owner"]; owner != "50001:50001" { + t.Errorf("%s: the directory its identity's container mounts is owned by %v", node, owner) + } + if owner, has := got["librarian.notes"]["owner"]; has { + t.Errorf("%s: a directory no identity container mounts was given the owner %v: what writes it is not "+ + "the identity", node, owner) + } + raw, _ := json.Marshal(composed.Identities) + seen = append(seen, string(raw)) + if !strings.Contains(string(raw), `"name":"mesh-librarian"`) || !strings.Contains(string(raw), `"uid":50001`) { + t.Errorf("%s: the declaration's identities are %s", node, raw) + } + } + // What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused. + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + with := keepingFolders(identities, "/storage/media/movies") + composed := composeFolders(t, r, with) + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources, + "identities": composed.Identities}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("the pinned node-engine refuses it: %v", problems) + } + if seen[0] != seen[1] { + t.Fatalf("the identities differ between machines: %s and %s", seen[0], seen[1]) + } +} + +// Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring +// identity mounts it, never a container declaring none — and the tool runner's account by name, with no number, +// each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so +// the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken. +func TestAnAccessNamesItsUsers(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + with := keepingFolders(identities, "/storage/media/movies") + got := byID(composeFolders(t, r, with).Resources) + + users := func(id string) string { + raw, _ := json.Marshal(got[id]["users"]) + return string(raw) + } + movies := "librarian." + AccessID("/storage/media/movies") + if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` + + `{"identity":"jochen","use":"read-write","from":"tool-runner"}]`; users(movies) != want { + t.Errorf("the librarian's movies name %s; want %s", users(movies), want) + } + played := "player." + AccessID("/storage/media/movies") + if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` + + `{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(played) != want { + t.Errorf("the player's movies name %s; want %s", users(played), want) + } + // The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools. + music := "librarian." + AccessID("/storage/media/music") + if want := `[{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(music) != want { + t.Errorf("the music names %s; want only the tool runner's read", users(music)) + } + if got[movies]["taken"] != true || got[music]["taken"] != false { + t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"]) + } + + // The runner is named with no number: the engine resolves it by name, never as uid 0 sent from here. + if strings.Contains(users(music), `"uid"`) { + t.Errorf("the tool runner's entry carries a number: %s", users(music)) + } +} + +// The contract: to an engine that does not state link.IdentityContract nothing of ADR 0325 is sent — no identities, no +// container identity, no users, no taken, no owner from an identity — and what is sent is what the pinned node-engine +// accepts, as before. +func TestNothingOfTheIdentityIsSentToAnOlderEngine(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + composed := composeFolders(t, r, Rendering{Identities: identities}) + if len(composed.Identities) > 0 { + t.Fatalf("identities were composed for an older engine: %v", composed.Identities) + } + for _, res := range composed.Resources { + for _, field := range []string{IdentityField, "users", "taken"} { + if _, has := res[field]; has { + t.Errorf("%v carries %s for an older engine", res["id"], field) + } + } + if res["id"] == "librarian.config" && res["owner"] != nil { + t.Errorf("the directory was given the owner %v for an older engine", res["owner"]) + } + } + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("the pinned node-engine refuses it: %v", problems) + } + // An engine that keeps folders, with no allocation yet (a question asked before the first send): composed + // without an identity, never with a made-up one. + none := composeFolders(t, r, keepingFolders(nil)) + if len(none.Identities) > 0 || byID(none.Resources)["librarian.app"][IdentityField] != nil { + t.Fatal("an identity was composed with no allocation") + } +} + +// Row 11: no identity outside the range is ever sent — 0 above all — and the composition says why. +func TestNoModuleIdentityIsRoot(t *testing.T) { + for _, n := range []int{0, 4, 10, 1000, IdentityLow - 1, IdentityHigh + 1} { + if AllocatableID(n) { + t.Errorf("%d is allocatable", n) + } + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}} + with := keepingFolders(map[string]ModuleIdentity{"librarian": {UID: n, GID: n}}) + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§11") { + t.Errorf("an identity of %d composed: %v", n, err) + } + } + for _, n := range []int{IdentityLow, IdentityHigh} { + if !AllocatableID(n) { + t.Errorf("%d is not allocatable", n) + } + } +} + +func manifestJSON(t *testing.T, m map[string]any) []byte { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +func aContainerModule(name string, resources ...map[string]any) map[string]any { + return map[string]any{"module": name, "version": "1", "capabilities": []any{"container-runtime"}, + "accesses": []any{map[string]any{"id": "movies", "path": "/storage/media/movies", "mode": "read-write"}}, + "resources": resources} +} + +// Rows 1 and 2: the module check accepts identity as env: or run and refuses every other shape and an owner in +// it; a module that declares identity is refused a directory naming its owner and a container declaring none that +// mounts its own directory or a read-write access; a directory's mode is accepted. +func TestTheModuleCheckHoldsAnIdentityModuleToTheDecision(t *testing.T) { + ok := func(identity any) map[string]any { + return map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage, + "identity": identity, "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + } + config := map[string]any{"id": "config", "type": "directory", "mode": "0750"} + for _, identity := range []any{ + map[string]any{"user": "env:PUID", "group": "env:PGID"}, + map[string]any{"user": "run"}, + } { + if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(identity)))); err != nil { + t.Errorf("identity %v refused: %v", identity, err) + } + } + for _, c := range []struct { + identity any + want string + }{ + {map[string]any{"user": "1000", "group": "1000"}, `"env:" or "run"`}, + {map[string]any{"user": "env:PUID"}, "told its group the same way"}, + {map[string]any{"user": "run", "group": "env:PGID"}, "takes no group"}, + {map[string]any{"user": "env:puid", "group": "env:PGID"}, "environment variable's name"}, + {map[string]any{"user": "env:PUID", "group": "env:PGID", "owner": "1000:1000"}, "never an owner or a number"}, + {"run", "identity is"}, + } { + _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(c.identity)))) + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "ADR 0325") { + t.Errorf("identity %v: %v, want it refused saying %q", c.identity, err, c.want) + } + } + // An identity on anything but a container. + onAFile := map[string]any{"id": "f", "type": "directory", "mode": "0750", "identity": map[string]any{"user": "run"}} + if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", onAFile))); err == nil { + t.Error("an identity on a directory was accepted") + } + + // Held to the decision whole once it declares identity (§1, §2, §3). + owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"} + bare := map[string]any{"id": "helper", "type": "container", "name": "helper", "image": identityImage, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + _, err := ParseManifest(manifestJSON(t, aContainerModule("app", owned, ok(map[string]any{"user": "run"}), bare))) + for _, want := range []string{"names its owner 1000:1000", "declares no identity and mounts the module's own directory config", + "declares no identity and mounts the read-write access movies"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("an identity module: %v, want it refused saying %q", err, want) + } + } + // A setting's owner too. + settingOwned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "${setting:puid}:${setting:pgid}"} + identityModule := aContainerModule("app", settingOwned, ok(map[string]any{"user": "run"})) + identityModule["settings"] = map[string]any{"puid": map[string]any{"default": "1000"}, "pgid": map[string]any{"default": "1000"}} + if _, err := ParseManifest(manifestJSON(t, identityModule)); err == nil || !strings.Contains(err.Error(), "names its owner") { + t.Errorf("a setting's owner in an identity module: %v", err) + } +} + +// Rows 1 and 2 for what is registered today: a module that declares no identity is warned, never refused — a +// directory naming its owner, a container mounting its own directory or a read-write access — and each warning +// names ADR 0325. A placement's owner is refused for an identity module and warned of for any other. +func TestAModuleWithoutIdentityIsWarnedNotRefused(t *testing.T) { + owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"} + bare := map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + m, err := ParseManifest(manifestJSON(t, aContainerModule("legacy", owned, bare))) + if err != nil { + t.Fatalf("a registered shape was refused: %v", err) + } + warnings := IdentityWarnings(m) + sort.Strings(warnings) + if len(warnings) != 3 { + t.Fatalf("warned %d times: %q", len(warnings), warnings) + } + for _, w := range warnings { + if !strings.Contains(w, "ADR 0325") || !strings.Contains(w, "refused once they have moved") { + t.Errorf("a warning that does not name ADR 0325 or say refusal comes later: %q", w) + } + } + + placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/srv/legacy", "owner": "1000:1000"}}}}} + if err := PlacesOwnerProblem(m, placed); err != nil { + t.Errorf("a placement's owner refused for a module without identity: %v", err) + } + if w := PlacesOwnerWarnings(m, placed); len(w) != 1 || !strings.Contains(w[0], "places.config.owner") { + t.Errorf("a placement's owner warned %q", w) + } + withIdentity := aLibrarian(t) + placedLibrarian := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/srv/librarian", "owner": "1000:1000"}}}}} + if err := PlacesOwnerProblem(withIdentity, placedLibrarian); err == nil || !strings.Contains(err.Error(), "ADR 0325") { + t.Errorf("a placement's owner for an identity module: %v", err) + } + if len(IdentityWarnings(withIdentity)) != 0 { + t.Error("an identity module was warned rather than refused") + } +} + +// A container is not switched to its identity while a read-write folder it mounts holds data the mesh has not taken +// (the operator's question on ADR 0325): the app would run as the new uid before the take adds its entry, and lose +// write on the existing files. Its identity is held — no `identity` sent, its directories keep their owner — while +// the take's users already name the identity, so the entries exist before the switch; once every such folder is +// taken (or kept by the engine), the identity is sent in one apply. +func TestAContainerKeepsItsOldIdentityUntilItsDataIsTaken(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}} + held := byID(composeFolders(t, r, keepingFolders(identities)).Resources) + if _, has := held["librarian.app"][IdentityField]; has { + t.Fatalf("the container was told its identity while its movies are not taken: %v", held["librarian.app"][IdentityField]) + } + if owner, has := held["librarian.config"]["owner"]; has { + t.Fatalf("its directory was given the owner %v while its identity is held", owner) + } + users, _ := json.Marshal(held["librarian."+AccessID("/storage/media/movies")]["users"]) + if !strings.Contains(string(users), `"identity":"mesh-librarian","uid":50001`) { + t.Fatalf("the access does not name the identity to come, so the take would not add its entry: %s", users) + } + + for name, with := range map[string]Rendering{ + "taken": keepingFolders(identities, "/storage/media/movies"), + "kept by the engine": func() Rendering { + w := keepingFolders(identities) + w.ReadyFolders = map[string]bool{"/storage/media/movies": true} + return w + }(), + } { + got := byID(composeFolders(t, r, with).Resources) + if _, has := got["librarian.app"][IdentityField]; !has { + t.Errorf("%s: the container is not told its identity", name) + } + if got["librarian.config"]["owner"] != "50001:50001" { + t.Errorf("%s: its directory is owned by %v", name, got["librarian.config"]["owner"]) + } + } +} diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index 400c5b3f..9425e8bc 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -1,6 +1,7 @@ package catalogue import ( + "errors" "fmt" "path/filepath" "regexp" @@ -270,6 +271,31 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) { // It refuses an id the module declares no access under, and a path that is not absolute. An // access declared by path alone cannot be placed — it has no name to place it by. func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) { + return accessPlaces(m, layers, true) +} + +// UndeclaredAccessError is a layer placing an access the definition does not declare (novox/hq issue 575): refused +// where a setting is stored, unless a newer build of the module declares it; passed by where a machine is composed. +type UndeclaredAccessError struct { + Module, ID string + said string +} + +func (e *UndeclaredAccessError) Error() string { return e.said } + +// UndeclaredAccessPlaces refuses a layer that places an access the definition does not declare. +func UndeclaredAccessPlaces(m Manifest, layers []Layer) error { + _, err := accessPlaces(m, layers, true) + var undeclared *UndeclaredAccessError + if errors.As(err, &undeclared) { + return err + } + return nil +} + +// accessPlaces is AccessPlaces; where strict is false an access the definition does not declare is passed by +// (novox/hq issue 575), as a placement stored ahead of the build that declares it. +func accessPlaces(m Manifest, layers []Layer, strict bool) (map[string]string, error) { declared := map[string]bool{} for _, a := range m.Accesses { if a.ID != "" { @@ -289,9 +315,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) { } for id, body := range blocks { if !declared[id] { - return nil, fmt.Errorf( + if !strict { + continue + } + return nil, &UndeclaredAccessError{Module: m.Module, ID: id, said: fmt.Sprintf( "%s places the access %q, which it does not declare — the setting reaches "+ - "nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m))) + "nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))} } path, _ := body.(string) path = strings.TrimSpace(path) @@ -324,7 +353,7 @@ type placedAccess struct { // placed one, the definition's where it carries a default, and refused where neither says — an // access that resolves to nowhere would reach the machine as a mount of nothing. func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) { - placed, err := AccessPlaces(m, layers) + placed, err := accessPlaces(m, layers, false) if err != nil { return nil, nil, err } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 534c7424..f657ba4c 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -509,6 +509,18 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) // refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read, // and never costs a module its place. func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { + if err := UndeclaredAccessPlaces(m, layers); err != nil { + return err + } + return ComposesWith(m, layers, adopted) +} + +// ComposesWith is JudgeSettings for a layer stored ahead of the definition that reads it (novox/hq issue 575): an +// access placed that this definition does not declare yet is not refused, because composition passes it by — it +// reaches nothing until the build that declares it registers, and then it places that access. Where a machine is +// declared (LeftOutBecause), and where a setting judged against a newer build of the module is held to still compose +// with the one registered. +func ComposesWith(m Manifest, layers []Layer, adopted bool) error { if err := settingsHoldOneLine(m.Module, layers); err != nil { return err } diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 6ce3740c..652dc5ee 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -958,7 +958,39 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, } layers = append(layers, catalogue.Layer{From: from, Values: values}) if err := catalogue.JudgeSettings(m, layers, adopted); err != nil { - return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err) + // **A placement for an access the module's newer build brings** (novox/hq issue 575): judged against the + // manifest of the module's newest build on the branch it follows, where the registered one does not declare + // the access yet — so an access can be placed before or with the delivery that adds it. It must still compose + // with the registered manifest, which passes the placement by until the build registers. + var undeclared *catalogue.UndeclaredAccessError + if !errors.As(err, &undeclared) { + return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err) + } + ahead, has, aerr := i.NewestManifestOnBranch(ctx, module) + if aerr != nil { + return aerr + } + if !has { + follows, ferr := i.followsABranch(ctx, module) + if ferr != nil { + return ferr + } + if !follows { + return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; and %s follows no branch "+ + "(it was registered by hand), so no build of it can be judged ahead of what is registered", module, + where, from, err, module) + } + } + if !has || catalogue.JudgeSettings(ahead, layers, adopted) != nil || catalogue.ComposesWith(m, layers, adopted) != nil { + return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; nor does the newest build of "+ + "%s on the branch it follows declare it", module, where, from, err, module) + } + m = ahead + } + // A module that runs as its own identity owns its directories by it: no setting names their owner (novox/hq ADR + // 0325 §1, §3). A module that does not yet keeps its placed owner, warned of where it is set. + if err := catalogue.PlacesOwnerProblem(m, layers); err != nil { + return fmt.Errorf("refused: %w", err) } // And a key that reaches nothing, refused here where somebody can still fix the spelling: // stored, it would be a setting somebody believes they made. @@ -969,6 +1001,40 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, return nil } +// followsABranch says a module's record names the branch it follows. +func (i *Inventory) followsABranch(ctx context.Context, module string) (bool, error) { + var ref string + err := i.store.Pool().QueryRow(ctx, `select coalesce(ref, '') from module where name = $1`, module).Scan(&ref) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return ref != "", err +} + +// NewestManifestOnBranch is the manifest of a module's newest successful build on the branch its record follows +// (novox/hq issue 575): what the delivery under way brings, before it registers — or the registered one's own, when +// nothing newer was built. Only a build of that very branch: never one of another branch or of none. False when no +// such build is kept, and for a module whose record follows no branch (registered by hand). +func (i *Inventory) NewestManifestOnBranch(ctx context.Context, module string) (catalogue.Manifest, bool, error) { + var raw []byte + err := i.store.Pool().QueryRow(ctx, + `select b.manifest from build b join module m on m.name = b.module + where b.module = $1 and b.failed = '' and b.manifest is not null and b.manifest::text <> 'null' + and coalesce(m.ref, '') <> '' and b.ref = m.ref + order by b.at desc limit 1`, module).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return catalogue.Manifest{}, false, nil + } + if err != nil { + return catalogue.Manifest{}, false, err + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + return catalogue.Manifest{}, false, nil // a build the mesh cannot read judges nothing + } + return m, true, nil +} + // givenIn is the machine ports a node-level settings layer gives a module, software port → // machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left // for composition to refuse in its own words. diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 1ee5a829..89e31e93 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -36,15 +36,37 @@ type ResourceHealth struct { Root string `json:"root,omitempty"` // Waits is what a waiting resource waits for the operator to give (novox/hq ADR 0283). Waits []Wait `json:"waits,omitempty"` + // Entries is a kept folder's listing as the engine resolved it (novox/hq ADR 0325 §4): each user with its uid. + Entries []KeptEntry `json:"entries,omitempty"` } -// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret and Setting -// (novox/hq ADR 0283), as link.Wait carries it. +// KeptEntry is one user of a kept folder as the engine lists it, with a note beside the tool runner's. +type KeptEntry struct { + Identity string `json:"identity"` + UID int `json:"uid"` + Use string `json:"use"` + From string `json:"from"` + Note string `json:"note,omitempty"` +} + +// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret, Setting and Access +// (novox/hq ADR 0283, ADR 0325 §7), as link.Wait carries it. type Wait struct { Part string `json:"part"` Secret string `json:"secret,omitempty"` Setting string `json:"setting,omitempty"` - What string `json:"what"` + // Access is an access of the module by its own id for it, from the engine's access-waits, and Disk that it + // waits for the disk that holds its folder rather than for the operator's word (novox/hq ADR 0325 §7, §9). + Access string `json:"access,omitempty"` + Disk bool `json:"disk,omitempty"` + // Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not + // answer the wait. + Placed string `json:"placed,omitempty"` + // Reason is the engine's reason for an access wait ("keep" or "disk"), ACL what its folder's filesystem says of + // access entries ("needs-enabling", "unsupported", or empty when it allows them). + Reason string `json:"reason,omitempty"` + ACL string `json:"acl,omitempty"` + What string `json:"what"` } // NodeHealth is a machine's newest statement, as kept. diff --git a/internal/inventory/identities.go b/internal/inventory/identities.go new file mode 100644 index 00000000..3c971def --- /dev/null +++ b/internal/inventory/identities.go @@ -0,0 +1,127 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Module identities and taken folders (novox/hq ADR 0325 §3, §8, §11; migration 0096). + +// AllocateIdentity is module's identity, allocated now when it has none: one number from the range of its own, +// mesh-wide, kept for ever. Two controllers allocating at once agree: the second's insert finds the first's row. +func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) { + if strings.TrimSpace(module) == "" { + return catalogue.ModuleIdentity{}, errors.New("an identity is allocated for a module, and none was named") + } + if id, had, err := i.IdentityOf(ctx, module); err != nil || had { + return id, err + } + // The number is drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never + // a number given twice. + _, err := i.store.Pool().Exec(ctx, + `insert into module_identity (module, uid, gid) + select $1, n, n from (select nextval('module_identity_number')::integer as n) drawn + on conflict (module) do nothing`, module) + if err != nil { + return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given an identity: %w", module, err) + } + id, had, err := i.IdentityOf(ctx, module) + if err == nil && !had { + err = fmt.Errorf("%s was given an identity and it cannot be read back", module) + } + return id, err +} + +// IdentityOf is module's identity, and false when it was never allocated one. +func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) { + id := catalogue.ModuleIdentity{Module: module} + err := i.store.Pool().QueryRow(ctx, `select uid, gid from module_identity where module = $1`, module). + Scan(&id.UID, &id.GID) + if errors.Is(err, pgx.ErrNoRows) { + return catalogue.ModuleIdentity{}, false, nil + } + if err != nil { + return catalogue.ModuleIdentity{}, false, err + } + return id, true, nil +} + +// Identities is every module's identity, by module. +func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) { + rows, err := i.store.Pool().Query(ctx, `select module, uid, gid from module_identity`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]catalogue.ModuleIdentity{} + for rows.Next() { + var id catalogue.ModuleIdentity + if err := rows.Scan(&id.Module, &id.UID, &id.GID); err != nil { + return nil, err + } + out[id.Module] = id + } + return out, rows.Err() +} + +// TakenFolder is one folder the operator let the mesh keep on a machine (ADR 0325 §8). +type TakenFolder struct { + Path string + By string + Ask string + Warrant string + Said string + // Placed is the filesystem the folder was on when taken, without its path (ADR 0325 §8). + Placed string +} + +// KeptFolders is every folder taken on a machine, by its clean path. +func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) { + rows, err := i.store.Pool().Query(ctx, + `select k.path, k.taken_by, k.ask, k.warrant, k.said, k.placed from kept_folder k join node n on n.id = k.node + where n.name = $1`, nodeName) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]TakenFolder{} + for rows.Next() { + var t TakenFolder + if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Placed); err != nil { + return nil, err + } + out[t.Path] = t + } + return out, rows.Err() +} + +// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once per +// filesystem: a second for the same folder on the same filesystem keeps the first, and one for the folder on another +// filesystem (it moved, and the operator was asked again) replaces it. +func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error { + switch { + case !strings.HasPrefix(t.Path, "/"): + return fmt.Errorf("a taken folder is an absolute path, not %q", t.Path) + case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "": + return errors.New("a folder is taken only on the operator's warrant, and this names none") + } + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `insert into kept_folder (node, path, taken_by, ask, warrant, said, placed) values ($1, $2, $3, $4, $5, $6, $7) + on conflict (node, path) do update + set taken_at = now(), taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant, + said = excluded.said, placed = excluded.placed + where kept_folder.placed is distinct from excluded.placed`, node.ID, filepath.Clean(t.Path), t.By, t.Ask, + t.Warrant, t.Said, t.Placed) + return err +} diff --git a/internal/inventory/identities_test.go b/internal/inventory/identities_test.go new file mode 100644 index 00000000..3d979e9f --- /dev/null +++ b/internal/inventory/identities_test.go @@ -0,0 +1,224 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0325 §3, §11: a module gets one allocation, the same whoever asks and however often, from the range +// of its own; no allocation is 0 or a root group's; a number once given is never given again. +func TestAModuleIsGivenOneIdentityOnceAndNeverRoot(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + radarr, err := inv.AllocateIdentity(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + again, err := inv.AllocateIdentity(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + if radarr != again { + t.Fatalf("radarr was given %+v, then %+v: one allocation per module", radarr, again) + } + plex, err := inv.AllocateIdentity(ctx, "plex") + if err != nil { + t.Fatal(err) + } + for _, id := range []catalogue.ModuleIdentity{radarr, plex} { + if id.UID == 0 || id.GID == 0 || !catalogue.AllocatableID(id.UID) || !catalogue.AllocatableID(id.GID) || + id.UID != id.GID { + t.Errorf("%s was given %d:%d: within %d–%d, never 0, uid and gid one number", id.Module, id.UID, id.GID, + catalogue.IdentityLow, catalogue.IdentityHigh) + } + } + if radarr.UID == plex.UID { + t.Fatalf("radarr and plex share %d", plex.UID) + } + + // Read back as composition reads it, from another connection's view: the same numbers (stable across restarts + // and machines, since every machine's composition reads this one row). + all, err := inv.Identities(ctx) + if err != nil { + t.Fatal(err) + } + if all["radarr"].UID != radarr.UID || all["plex"].UID != plex.UID { + t.Fatalf("the identities read back are %+v", all) + } + + // A module forgotten keeps its row, and the next module is given a new number: never one given before. + if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil { + t.Fatal(err) + } + sonarr, err := inv.AllocateIdentity(ctx, "sonarr") + if err != nil { + t.Fatal(err) + } + if sonarr.UID == plex.UID || sonarr.UID == radarr.UID { + t.Fatalf("sonarr was given %d, a number given before", sonarr.UID) + } + + // The store itself refuses a number outside the range, root's above all: no write by hand makes one root. + for _, n := range []int{0, 10, 1000, 49999, 60000} { + if _, err := inv.store.Pool().Exec(ctx, + `insert into module_identity (module, uid, gid) values ($1, $2, $2)`, "byhand", n); err == nil { + t.Errorf("the store took %d as an identity", n) + } + } +} + +// novox/hq ADR 0325 §8: a folder taken is recorded once per machine, under a warrant, and read back by its path. +func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "ace"); err != nil { + t.Fatal(err) + } + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies/", By: "the operator"}); err == nil { + t.Fatal("a folder was recorded taken with no warrant") + } + first := TakenFolder{Path: "/storage/media/movies/", By: "the operator", Ask: "c01", Warrant: "c01", Said: "started"} + if err := inv.RecordTaken(ctx, "ace", first); err != nil { + t.Fatal(err) + } + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "someone else", Ask: "c02", + Warrant: "c02"}); err != nil { + t.Fatal(err) + } + kept, err := inv.KeptFolders(ctx, "ace") + if err != nil { + t.Fatal(err) + } + got, has := kept["/storage/media/movies"] + if !has || got.Ask != "c01" || got.By != "the operator" { + t.Fatalf("the taken folder reads %+v (%v): the first take, by its clean path", got, has) + } + // The folder moved to another filesystem and the operator was asked again: the new take replaces the old. + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "the operator", Ask: "c03", + Warrant: "c03", Placed: "fs-0000000000ff"}); err != nil { + t.Fatal(err) + } + kept, err = inv.KeptFolders(ctx, "ace") + if err != nil { + t.Fatal(err) + } + if got := kept["/storage/media/movies"]; got.Ask != "c03" || got.Placed != "fs-0000000000ff" { + t.Fatalf("after a take on another filesystem the folder reads %+v", got) + } +} + +// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it — +// the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused. +// Without such a build it is refused, as before. +func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "ace"); err != nil { + t.Fatal(err) + } + registered := catalogue.Manifest{Module: "media-importer", Version: "1", + Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x", + "volumes": []any{"${access:downloads}:/downloads"}}}} + if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", + Ref: "main", BuiltFrom: "c0ffee33"}); err != nil { + t.Fatal(err) + } + placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}} + + err := inv.JudgeSettings(ctx, "ace", "media-importer", placing) + if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) { + t.Fatalf("a placement no build declares: %v", err) + } + + // main's manifest — the build of #34 — declares movies. + ahead := registered + ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...) + ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies", + Mode: catalogue.AccessReadWrite}) + raw, err := json.Marshal(ahead) + if err != nil { + t.Fatal(err) + } + b := aBuild("b34", "media-importer", "") + b.Ref, b.Manifest = "main", raw + if err := inv.RecordBuild(ctx, b); err != nil { + t.Fatal(err) + } + if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil { + t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err) + } + if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil { + t.Fatalf("the layer placing movies was not stored: %v", err) + } + // Stored ahead, it does not cost the registered module its place: composition passes it by. + layers, err := inv.SettingsFor(ctx, "ace", "media-importer") + if err != nil { + t.Fatal(err) + } + if err := catalogue.ComposesWith(registered, layers, false); err != nil { + t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err) + } + // A typo is still a typo: an access neither build declares is refused. + typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}} + if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil { + t.Fatal("a placement no build declares was taken") + } +} + +// Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one +// from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so. +func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "ace"); err != nil { + t.Fatal(err) + } + registered := catalogue.Manifest{Module: "importer", Version: "1", + Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}} + ahead := registered + ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}} + raw, err := json.Marshal(ahead) + if err != nil { + t.Fatal(err) + } + placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}} + if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main", + BuiltFrom: "c0ffee"}); err != nil { + t.Fatal(err) + } + for i, ref := range []string{"feature", ""} { + b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "") + b.Ref, b.Manifest = ref, raw + if err := inv.RecordBuild(ctx, b); err != nil { + t.Fatal(err) + } + } + if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil { + t.Fatal("a build of another branch, or of none, judged the placement") + } + // Registered by hand, with no branch: refused, saying why. + byHand := registered + byHand.Module = "byhand" + if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil { + t.Fatal(err) + } + aheadByHand := ahead + aheadByHand.Module = "byhand" + if raw, err = json.Marshal(aheadByHand); err != nil { + t.Fatal(err) + } + b := aBuild("onmain", "byhand", "") + b.Ref, b.Manifest = "main", raw + if err := inv.RecordBuild(ctx, b); err != nil { + t.Fatal(err) + } + if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil || + !strings.Contains(err.Error(), "follows no branch") { + t.Fatalf("a module with no branch recorded: %v", err) + } +} diff --git a/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql new file mode 100644 index 00000000..34eb6024 --- /dev/null +++ b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql @@ -0,0 +1,44 @@ +-- Every module is given an identity of its own, and the mesh takes a shared folder once, on the operator's word +-- (novox/hq ADR 0325 §3, §8, §11). +-- +-- **One number per module, mesh-wide, allocated once and never again.** The node-engine makes the account +-- mesh- on each machine the module is assigned to, with these numbers, so a container that declares +-- `identity` runs as the same uid and gid everywhere and a folder's entries mean the same on every machine. The +-- numbers come from a sequence of their own, 50000–59999 (catalogue.IdentityLow and IdentityHigh): above the +-- numbers distributions give people, below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a +-- root group's (0, 4, 10). The checks refuse a row outside the range, so no write, by hand or by a later bug, can make +-- an identity root (§11). uid and gid are one number. A sequence never hands a number back, and a row is kept after +-- its module is forgotten, so a number once given is never given to another module: a file left owned by a removed +-- module's identity is never inherited by a new one. +create sequence module_identity_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle; + +create table module_identity ( + module text primary key, + uid integer not null unique check (uid between 50000 and 59999), + gid integer not null unique check (gid between 50000 and 59999), + allocated timestamptz not null default now(), + check (uid = gid) +); + +-- **A folder taken, per machine** (§8). The first time a placed folder that already holds data is to be kept, the +-- operator is asked once for that folder on that machine; on the warrant the node-engine adds the entries to +-- everything there, and the folder is taken: kept from then on without asking, a module that gains the access +-- added with no wait. The row is the record of that act — who approved it, under which warrant — and what tells a +-- later module's access that it joins a taken folder (`taken` on the access). The path is the folder as placed, +-- clean and absolute. A new warrant for the same folder on another filesystem replaces the row. +create table kept_folder ( + node uuid not null references node (id) on delete cascade, + path text not null check (path like '/%'), + taken_at timestamptz not null default now(), + -- Who approved it, as the hand-act log says it, and the ask and warrant it was taken under. + taken_by text not null, + ask text not null, + warrant text not null, + -- What the node-engine answered when it started the take. + said text not null default '', + -- The filesystem the folder was on when it was taken, as the engine names it without a path ("fs-<12 hex>"): + -- a wait naming another (the folder moved to another disk) is not answered by this take, and the operator is + -- asked again; until the new warrant the folder is not taken there. Empty from an engine that names none. + placed text not null default '', + primary key (node, path) +); diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 63349ece..3498e26d 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -8,6 +8,7 @@ package link import ( "encoding/base64" + "encoding/json" "github.com/novox/mesh-host/rootsearch" "strconv" "strings" @@ -436,6 +437,14 @@ const ReadinessContract = 2 // parses strictly and would refuse the whole declaration for it. const RootContract = 3 +// IdentityContract is the statement of an engine that gives each module its identity and keeps shared folders +// (novox/hq ADR 0325; mesh-host internal/link IdentityContract): it reads a declaration's `identities`, a container's +// resolved `identity`, an access's `users` and `taken`, states an access it may not keep yet as waiting +// (ResourceHealth.AccessWaits), and hears the take act (AskAccessAct). Only to such an engine is any of it sent: an +// older one parses strictly and would refuse the whole declaration for it. Four, the grant's number (ADR 0312, +// superseded), is what engines stated before; the controller sends no grant to any engine (#270). +const IdentityContract = 5 + // ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's // own words (mesh-host internal/accounts ReasonRoot). const ReasonRoot = "can become root without a person" @@ -580,6 +589,57 @@ type Wait struct { What string `json:"what"` } +// WaitKindAccess is the kind of a wait on an access (novox/hq ADR 0325 §7; mesh-host internal/link). +const WaitKindAccess = "access" + +// AccessWait is one access waiting for the operator (novox/hq ADR 0325 §7): the access by its module's own id for +// it, the part (its module), and what the operator gives, in words with no path — "let the mesh keep the shared +// folder movies", or "mount the disk that holds books". Beside Waits because mesh-sdk's health.Wait names a secret or +// a setting and nothing else. From an engine at IdentityContract or later. +type AccessWait struct { + Kind string `json:"kind"` + Access string `json:"access"` + Part string `json:"part"` + What string `json:"what"` + // Placed names, with no path, the filesystem the folder is on now ("fs-<12 hex>"), on a wait for the operator's + // word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks + // again (mesh-host internal/link AccessWait.Placed). + Placed string `json:"placed,omitempty"` + // Reason is what the wait is for: WaitKeep (the operator's word to keep the folder) or WaitDisk (its disk + // mounted). ACL is said on a keep wait whose filesystem refuses access entries now: ACLNeedsEnabling when the take + // act will let it, ACLUnsupported when no act can. Absent when it allows them. + Reason string `json:"reason"` + ACL string `json:"acl,omitempty"` +} + +// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link). +const ( + WaitKeep = "keep" + WaitDisk = "disk" + ACLNeedsEnabling = "needs-enabling" + ACLUnsupported = "unsupported" +) + +// KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user +// has its right, waiting while the mesh may not keep its folder yet or its disk is not there, unhealthy otherwise — +// and unknown when its filesystem could not be named this look (a hung mount), carrying no wait. +const KindAccess = "access" + +// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet +// because its disk was away (novox/hq ADR 0325 §8; mesh-host internal/link): unhealthy, said under the access that +// last named it (`~entries`), which may no longer be declared. The node-engine's own. +const KindKeptEntries = "kept-entries" + +// KeptEntry is one user of a kept folder as the engine's listing says it (ADR 0325 §4), with a note beside the tool +// runner's entry. +type KeptEntry struct { + Identity string `json:"identity"` + UID int `json:"uid"` + Use string `json:"use"` + From string `json:"from"` + Note string `json:"note,omitempty"` +} + // ResourceHealth is one long-running resource's state. type ResourceHealth struct { Module string `json:"module"` @@ -606,6 +666,11 @@ type ResourceHealth struct { // Waits is what a resource in StateWaiting waits for (novox/hq ADR 0283). Empty otherwise, and from an // engine older than that. Waits []Wait `json:"waits,omitempty"` + // AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7); Act the last walk under its + // folder, kept as the engine said it; Entries the kept folder's listing. From an engine at IdentityContract only. + AccessWaits []AccessWait `json:"access-waits,omitempty"` + Act json.RawMessage `json:"act,omitempty"` + Entries []KeptEntry `json:"entries,omitempty"` } // HealthSaid is the health event's body: the machine and its statement. The machine is read from the diff --git a/internal/link/take.go b/internal/link/take.go new file mode 100644 index 00000000..9462503f --- /dev/null +++ b/internal/link/take.go @@ -0,0 +1,114 @@ +package link + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" +) + +// The take act: a shared folder that already holds data is taken by the mesh once, on the operator's word +// (novox/hq ADR 0325 §8). +// +// The controller asks the operator once for that folder on that machine (ADR 0259, at the level approve). On the +// warrant it asks the machine's node-engine, on the engine's own subject (broker.AskAccessActSubject), signed with +// the mesh's key as a hand-over is (SignedHandOver) under a signing context of its own (AccessActContext), to take +// the folder: the engine adds the entries of the users it was declared for that folder to everything under it — +// following no link, entering no other filesystem — answers at once that it started or why it refused, and states +// the walk on the access's verdict. **Nothing is taken without a warrant**: the ask names the warrant the operator +// gave, and the controller sends it only from the warrant (cmd/mesh-controller decideTake). +// +// The engine's own shape (mesh-host internal/link AccessActAsk, AnswerAccessAct): a hand-over's fields and +// {act: "take", access: , warrant}. + +// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's signature, nor a +// declaration's. The engine holds the same words. +const AccessActContext = "novox-mesh access-act v2\n" + +// ActTake is the one act the controller asks. +const ActTake = "take" + +// AccessActAsk is what the controller asks: a hand-over's fields — the node it is for, the folder's absolute path as +// the declaration places it, who asked, when the ask stops being good and a nonce the engine takes once — and the +// act's own: which act, the access by its resource id, and the warrant the operator gave. +type AccessActAsk struct { + Node string `json:"node"` + Path string `json:"path"` + By string `json:"by"` + Expires time.Time `json:"expires"` + Nonce string `json:"nonce"` + Act string `json:"act"` + Access string `json:"access"` + Warrant string `json:"warrant"` +} + +// MaxActField bounds an access id and a warrant, as the engine does. +const MaxActField = 200 + +// TakeWithin is how long the controller waits for the engine's answer: it starts the walk and answers, and the +// walk's end is stated on the access's verdict. +const TakeWithin = 30 * time.Second + +// SignTake fills a take ask's act, expiry and nonce and signs it with the mesh's key over AccessActContext. It refuses +// an ask with no warrant, no path or no access — and one the engine would refuse for its shape: nothing is ever asked +// of an engine that it would have to refuse. +func SignTake(ctx context.Context, signer Signer, ask AccessActAsk) ([]byte, error) { + ask.Access, ask.Warrant = strings.TrimSpace(ask.Access), strings.TrimSpace(ask.Warrant) + switch { + case signer == nil: + return nil, errors.New("no signing key, so nothing can be asked of an engine") + case ask.Warrant == "": + return nil, errors.New("a take is asked only on the operator's warrant, and this one names none") + case ask.Node == "" || !strings.HasPrefix(ask.Path, "/"): + return nil, errors.New("a take names its machine and the folder's absolute path") + case ask.Access == "": + return nil, errors.New("a take names the access whose folder it takes") + } + // The warrant is one short name; the access is the resource id the declaration gave it, which may hold a path's + // slashes (catalogue.AccessID) — the engine judges it against what it was declared. + if len(ask.Warrant) > MaxActField || strings.ContainsAny(ask.Warrant, "\r\n\x00/") { + return nil, fmt.Errorf("the take's warrant %q is not one short name", ask.Warrant) + } + if len(ask.Access) > MaxActField || strings.ContainsAny(ask.Access, "\r\n\x00") { + return nil, fmt.Errorf("the take's access %q is not one line", ask.Access) + } + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + ask.Act, ask.Nonce, ask.Expires = ActTake, hex.EncodeToString(nonce), time.Now().UTC().Add(HandOverGood) + raw, err := json.Marshal(ask) + if err != nil { + return nil, err + } + signature, err := signer.Sign(ctx, append([]byte(AccessActContext), raw...)) + if err != nil { + return nil, fmt.Errorf("cannot sign the ask: %w", err) + } + return json.Marshal(SignedHandOver{Ask: raw, Signature: signature}) +} + +// AskTake asks one machine's node-engine to take a shared folder on the operator's warrant, and reads its answer. An +// error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's and comes back in +// the answer. +func AskTake(ctx context.Context, conn *nats.Conn, signer Signer, ask AccessActAsk, timeout time.Duration) (HandOverAnswer, error) { + if conn == nil { + return HandOverAnswer{}, errors.New("this controller is not on the bus") + } + body, err := SignTake(ctx, signer, ask) + if err != nil { + return HandOverAnswer{}, err + } + return askSigned(ctx, conn, broker.AskAccessActSubject(ask.Node), body, ask.Node, timeout, askWords{ + what: "the take of a shared folder", nothing: "nothing was taken", issue: "ADR 0325", + unknown: "whether it started is not known — the access's verdict on the machine says whether it is kept", + record: "a start"}) +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index a37baf3b..cd6e20a3 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev a second place to clean besides the catalogue itself. mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile -mesh-host a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock +mesh-host 303d4037e84ea4968da9880a11f4d5a374b3714a examples/foundation-first-node-nats.lock mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured b/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured index 01112d50..e23ef454 100644 --- a/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured +++ b/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured @@ -523,6 +523,20 @@ func refusedPlaces(resources []declaration.Resource) map[string]error { switch res := r.(type) { case *declaration.Directory: err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner) + // And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a + // link another account could make in its path refuses it, and with it what is below it, whatever + // order they are declared in. The apply walks again at the moment of use. A directory handed over + // keeps its own, stricter walk (issue 356), which refuses any link at all. + if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" && + !HandedOver()[handOverKey(res.ID, res.Path)] { + if judged := judgeMeshDir(res.Path); judged != nil { + err = judged + var link *LinkInPathError + if errors.As(judged, &link) { + err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()} + } + } + } case *declaration.Access: err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "") default: diff --git a/testdata/store-tests b/testdata/store-tests index 7586e2d0..b762e28b 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -35,3 +35,9 @@ ./cmd/mesh-controller TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend ./cmd/mesh-controller TestAGateJudgesTheOtherMovesBesideAKeptCore ./cmd/mesh-controller TestReplay401 +# novox/hq ADR 0325 §3, §8, §11 and issue 575: identities allocated once and never root, a folder taken under a +# warrant, a placement judged against the build that brings its access. +./internal/inventory TestAModuleIsGivenOneIdentityOnceAndNeverRoot +./internal/inventory TestAFolderIsTakenOnceUnderAWarrant +./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess +./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 50db892e..a8b89cd1 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -456,14 +456,56 @@ const ( // **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — // it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A // media library, a download spool is the operator's: it existed before the mesh, several modules -// read and write it at once, and the mesh must not create, chown, reconcile or remove it. The -// host confirms it is there and mounts it; nothing else. +// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it +// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it +// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the +// filesystem the data was placed on (§9). type Access struct { ID string `json:"id"` Type Type `json:"type"` Path string `json:"path"` // Mode is how this module reaches the path: read or read-write. Absent narrows to read. Mode string `json:"mode,omitempty"` + + // Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each + // module identity whose container mounts it, and the tool runner's account standing for every module whose + // tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several + // accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a + // controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept. + Users []AccessUser `json:"users,omitempty"` + // Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is + // kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty. + Taken bool `json:"taken,omitempty"` + + // Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted + // so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5). + Grant json.RawMessage `json:"grant,omitempty"` +} + +// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4). +const ( + FromContainer = "container" + FromToolRunner = "tool-runner" +) + +// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its +// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name. +type AccessUser struct { + Identity string `json:"identity"` + UID int `json:"uid,omitempty"` + Use string `json:"use"` + From string `json:"from"` +} + +// UnmarshalJSON reads a user as strictly as a resource is read. +func (u *AccessUser) UnmarshalJSON(raw []byte) error { + type plain AccessUser + var p plain + if err := strictDecode(raw, &p); err != nil { + return fmt.Errorf("an access's user: %w", err) + } + *u = AccessUser(p) + return nil } func (a *Access) Identity() string { return a.ID } @@ -482,9 +524,43 @@ func (a *Access) validate(where string, _ bool) []string { problems = append(problems, fmt.Sprintf( "%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode)) } + for _, u := range a.Users { + switch { + case u.Identity == "" || !accountName.MatchString(u.Identity): + problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one", + where, u.Identity)) + case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner): + // Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a + // number below it is no account. A tool runner's account alone may come without its number: the + // controller cannot know the operator's account's, and the engine reads it by name + // (apply.ResolveRunners), refusing one that is root's. + problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+ + "root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID)) + } + if u.Use != AccessRead && u.Use != AccessReadWrite { + problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where, + u.Identity, u.Use, AccessRead, AccessReadWrite)) + } + if u.Use == AccessReadWrite && a.Mode != AccessReadWrite { + problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where, + u.Identity, orRead(a.Mode))) + } + if u.From != FromContainer && u.From != FromToolRunner { + problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where, + u.Identity, u.From, FromContainer, FromToolRunner)) + } + } return problems } +// orRead is an access's mode as said: absent narrows to read. +func orRead(mode string) string { + if mode == "" { + return AccessRead + } + return mode +} + func (u *User) Identity() string { return u.ID } func (u *User) Kind() Type { return TypeUser } func (u *User) Target() string { return u.Name } @@ -1160,6 +1236,78 @@ type Container struct { // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. Health *Health `json:"health,omitempty"` + + // RunAs is who the container runs as, resolved by the controller from the module's identity and the + // image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and + // PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the + // container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because + // a resource's Identity is its id. + RunAs *ContainerIdentity `json:"identity,omitempty"` +} + +// The ways an image is told who to run as (novox/hq ADR 0325 §2). +const ( + IdentityByEnv = "env" + IdentityByRun = "run" +) + +// ContainerIdentity is who a container runs as, and how its image is told. +type ContainerIdentity struct { + // Mode is IdentityByEnv or IdentityByRun. + Mode string `json:"mode"` + // UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for + // IdentityByEnv: "PUID", "PGID". + UserEnv string `json:"user_env,omitempty"` + GroupEnv string `json:"group_env,omitempty"` + UID int `json:"uid"` + GID int `json:"gid"` +} + +// UnmarshalJSON reads an identity as strictly as a resource is read. +func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error { + type plain ContainerIdentity + var p plain + if err := strictDecode(raw, &p); err != nil { + return fmt.Errorf("a container's identity: %w", err) + } + *i = ContainerIdentity(p) + return nil +} + +// envName is a variable's name as a runtime takes it. +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (i *ContainerIdentity) problems(where string, env map[string]string) []string { + if i == nil { + return nil + } + var problems []string + switch i.Mode { + case IdentityByEnv: + if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) { + problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+ + "it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv)) + } + for _, name := range []string{i.UserEnv, i.GroupEnv} { + if _, set := env[name]; set && name != "" { + problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+ + "for one variable", where, name)) + } + } + case IdentityByRun: + if i.UserEnv != "" || i.GroupEnv != "" { + problems = append(problems, where+": an identity told by the runtime's user option names no variable") + } + default: + problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where, + IdentityByEnv, IdentityByRun, i.Mode)) + } + // Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started. + if i.UID <= 0 || i.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+ + "never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID)) + } + return problems } func (c *Container) Identity() string { return c.ID } @@ -1242,6 +1390,7 @@ func (c *Container) validate(where string, _ bool) []string { problems = append(problems, where+": networks is for a container that keeps running; a step "+ "runs and exits, and joins nothing afterwards") } + problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...) return append(problems, checkImage(where, c.Image)...) } @@ -1392,6 +1541,53 @@ type Declaration struct { // the generation of what it puts back, which may be older than what the machine applied, and is // not refused for it. PutBack bool + + // Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account + // and a group `mesh-` with the numbers the controller allocated once for the module, the same on + // every machine. The engine makes each before any resource is applied, and removes what it made of one no + // longer declared. Absent is a controller older than ADR 0325. + Identities []Identity +} + +// Identity is one module's identity, as the controller allocated it. +type Identity struct { + Module string `json:"module"` + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` +} + +// IdentityPrefix starts every module identity's name. +const IdentityPrefix = "mesh-" + +// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number +// twice — two modules with one account would write as each other. +func checkIdentities(ids []Identity) []string { + var problems []string + names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{} + for _, id := range ids { + where := fmt.Sprintf("identity %q", id.Name) + if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) { + problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s, and this one is "+ + "for the module %q", where, IdentityPrefix, id.Module)) + } + if id.UID <= 0 || id.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+ + "nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where, + id.UID, id.GID)) + } + if names[id.Name] { + problems = append(problems, where+": declared twice") + } + if other, used := uids[id.UID]; used { + problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other)) + } + if other, used := gids[id.GID]; used { + problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other)) + } + names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name + } + return problems } // LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the @@ -1561,6 +1757,9 @@ type envelope struct { // sends them only to a host whose reports say `reads_generation`. Generation int64 `json:"generation,omitempty"` PutBack bool `json:"put_back,omitempty"` + // Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a + // controller sends it only to a host whose health says link.IdentityContract. + Identities []Identity `json:"identities,omitempty"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1578,8 +1777,9 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { } d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, - Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack} - var problems []string + Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, + Identities: env.Identities} + problems := checkIdentities(env.Identities) if env.Generation < 0 { // Below zero would read as "none claimed" and pass every refusal of an older generation. problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+ @@ -1732,6 +1932,10 @@ func checkWhileStopped(resources []Resource) []string { return problems } +// accountName is what an identity's or an access user's name may be: the portable account name the user tools +// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument. +var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`) + func strictDecode(raw []byte, into any) error { // DisallowUnknownFields is the whole point rather than strictness for its own sake: a // field the host does not know is a thing the control plane believes it asked for. diff --git a/vendor/modules.txt b/vendor/modules.txt index 4a86d3fb..ea6d2f99 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -78,7 +78,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -138,4 +138,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e