From f634e3379287db064cdd5383e194452c332280b8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 22:29:35 +0200 Subject: [PATCH] Libraries and the modules that join them, on the controller's side (hq ADR 0328) --- cmd/mesh-controller/asker.go | 18 +- cmd/mesh-controller/check.go | 8 +- cmd/mesh-controller/gate.go | 8 +- cmd/mesh-controller/kept_folders.go | 394 -------- cmd/mesh-controller/kept_folders_test.go | 563 ------------ cmd/mesh-controller/kept_libraries.go | 382 ++++++++ cmd/mesh-controller/kept_libraries_test.go | 420 +++++++++ cmd/mesh-controller/module_health.go | 14 +- cmd/mesh-controller/modules.go | 9 - cmd/mesh-controller/operator_wait.go | 132 ++- cmd/mesh-controller/plan.go | 176 ++-- cmd/mesh-controller/sendable.go | 15 +- internal/catalogue/declaration.go | 98 +- internal/catalogue/libraries.go | 839 ++++++++++++++++++ internal/catalogue/libraries_test.go | 319 +++++++ internal/catalogue/manifest.go | 7 +- internal/catalogue/module_identity.go | 575 ------------ internal/catalogue/module_identity_test.go | 370 -------- internal/catalogue/runtime.go | 4 + internal/catalogue/settings.go | 20 + internal/catalogue/terminal_keys.go | 4 + internal/catalogue/unconsumed_placeholder.go | 6 + internal/inventory/catalogue.go | 13 +- internal/inventory/health.go | 15 +- internal/inventory/identities.go | 127 --- internal/inventory/identities_test.go | 224 ----- internal/inventory/libraries.go | 267 ++++++ internal/inventory/libraries_test.go | 300 +++++++ ...iven-an-identity-and-a-folder-is-taken.sql | 44 - ...module-joins-and-a-library-has-a-group.sql | 44 + internal/link/protocol.go | 37 +- testdata/store-tests | 9 +- 32 files changed, 2915 insertions(+), 2546 deletions(-) delete mode 100644 cmd/mesh-controller/kept_folders.go delete mode 100644 cmd/mesh-controller/kept_folders_test.go create mode 100644 cmd/mesh-controller/kept_libraries.go create mode 100644 cmd/mesh-controller/kept_libraries_test.go create mode 100644 internal/catalogue/libraries.go create mode 100644 internal/catalogue/libraries_test.go delete mode 100644 internal/catalogue/module_identity.go delete mode 100644 internal/catalogue/module_identity_test.go delete mode 100644 internal/inventory/identities.go delete mode 100644 internal/inventory/identities_test.go create mode 100644 internal/inventory/libraries.go create mode 100644 internal/inventory/libraries_test.go delete mode 100644 internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql create mode 100644 internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index d9a43c4f..d06e715a 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -117,9 +117,9 @@ 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"` + // Take is a library the operator is asked to let the mesh take (kept_libraries.go, novox/hq ADR 0328 §7): about no + // condition, taken on Approve by the serving controller itself, asked once per library. + Take *libraryTake `json:"take,omitempty"` } // ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal. @@ -195,9 +195,9 @@ 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) + // folders is every library waiting to be taken (novox/hq ADR 0328 §7), 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) ([]libraryTake, error) take takeOnWarrant machines func(ctx context.Context) ([]string, error) // record writes the hand-act log. @@ -562,9 +562,9 @@ 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). + // And every library waiting to be taken, once per library (novox/hq ADR 0328 §7). if err := a.askFolders(ctx); err != nil { - a.logf("the operator could not be asked about the folders waiting to be kept: %v", err) + a.logf("the operator could not be asked about the libraries waiting to be taken: %v", err) } why := "the router refused the ask" if len(refusedWords) > 0 { @@ -889,7 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { 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 + // A library taken on the operator's warrant (novox/hq ADR 0328 §7): 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") { diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index d40e05c9..4f54fda4 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -152,12 +152,10 @@ 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). + // **A module that uses several libraries is warned** (novox/hq ADR 0328 §4): an image that drops added groups when + // it changes user reaches only the first. Printed, never counted. for _, name := range names { - for _, w := range catalogue.IdentityWarnings(shelf[name]) { + for _, w := range catalogue.LibraryWarnings(shelf[name]) { fmt.Fprintf(out, "warning: %s\n", w) } } diff --git a/cmd/mesh-controller/gate.go b/cmd/mesh-controller/gate.go index 73df4362..78628f82 100644 --- a/cmd/mesh-controller/gate.go +++ b/cmd/mesh-controller/gate.go @@ -819,11 +819,11 @@ func movesChangingAccesses(ctx context.Context, inv *inventory.Inventory, g *inv 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. +// sameAccessUse says a move leaves a module's accesses and libraries as they were (the review of e1c2b744, novox/hq +// ADR 0328): the same accesses at the same use, the same library entries, and whether it joins — a module that starts +// joining runs as another user, and changes who uses its libraries. func sameAccessUse(a, b catalogue.Manifest) bool { - return sameAccesses(a.Accesses, b.Accesses) && catalogue.AccessUsersShape(a) == catalogue.AccessUsersShape(b) + return sameAccesses(a.Accesses, b.Accesses) && catalogue.LibraryUseShape(a) == catalogue.LibraryUseShape(b) } // sameAccesses says two manifests declare the same accesses, at the same use, in any order. diff --git a/cmd/mesh-controller/kept_folders.go b/cmd/mesh-controller/kept_folders.go deleted file mode 100644 index 369017d9..00000000 --- a/cmd/mesh-controller/kept_folders.go +++ /dev/null @@ -1,394 +0,0 @@ -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 deleted file mode 100644 index e27b5998..00000000 --- a/cmd/mesh-controller/kept_folders_test.go +++ /dev/null @@ -1,563 +0,0 @@ -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/kept_libraries.go b/cmd/mesh-controller/kept_libraries.go new file mode 100644 index 00000000..cac22a78 --- /dev/null +++ b/cmd/mesh-controller/kept_libraries.go @@ -0,0 +1,382 @@ +package main + +// Existing data joins a library once, on the operator's word (novox/hq ADR 0328 §7, §8). +// +// **What waits.** A node-engine that keeps libraries states a library waiting, with an access wait of reason `keep`, +// while its root on its home holds data not yet in its group; a module that uses it waits the same way. The +// controller excuses such a wait only for a defined library that reaches the machine and is not yet taken on its home, +// on the filesystem the wait names (checkLibraryWait), and the module is said as its needs-operator condition, in words +// with no path. +// +// **One ask per library.** The serving controller's asker asks the operator once for each such library — however many +// modules and machines wait on it — at the level approve (ADR 0259), in the clause's words: "Let the mesh keep library +// at on : …". It is kept in the controller's asks beside a proposal (asked.Take), never a +// condition's. While it is open no second is asked; one that expired unanswered is asked again while the library +// waits; one about a library that no longer waits is taken back; one whose library moved to another filesystem is +// asked again for the one it is on now. None is asked for a library already taken. +// +// **On the warrant, and only on it**, the controller asks the library's home engine to take it (link.AskTake), naming +// "library:" and the warrant — after checking the library still waits on the filesystem the operator was asked +// about. On the engine's word that it started, the library is recorded taken (kept_library), and from the next send +// the library says `taken`. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/catalogue" + "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" +) + +// libraryTake is one library the operator is asked to let the mesh take, 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 libraryTake struct { + Library string `json:"library"` + // Node is the library's home, where it is taken; Root its root there. + Node string `json:"node"` + Root string `json:"root"` + // Modules are the modules waiting on it, sorted. + Modules []string `json:"modules"` + // Placed is the filesystem the root is on, as the home's engine names it without a path: the warrant is for it. + 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 library on its home and filesystem: what one ask is kept for. A library that moved is asked again. +func (t libraryTake) key() string { return t.Library + "\x00" + t.Node + "\x00" + t.Placed } + +// digest binds everything the take does and was said for. +func (t libraryTake) digest() string { + raw, _ := json.Marshal(struct { + Library, Node, Root, Placed string + Modules []string + }{t.Library, t.Node, t.Root, t.Placed, t.Modules}) + 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 libraryTake) 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, "library": t.Library, + "node": t.Node, "access": libraryAccess + t.Library, "digest": t.digest()}}} +} + +// takeWords is the clause's ask (ADR 0328 §7), with the root as shown. +func takeWords(library, shown, machine string) string { + return fmt.Sprintf("Let the mesh keep library %s at %s on %s: its group becomes %s, the group may read and write, "+ + "folders keep the group; owners and everyone else's rights stay as they are", library, shown, machine, + catalogue.MeshName(library)) +} + +// ask is the take's ask, composed from it alone. The root is said whole where the router's content rule lets it leave +// the mesh, and otherwise as "its root". +func (t libraryTake) ask(id string, machines []string) (asks.Ask, map[string]int) { + shown := t.Root + if _, ok := outward.Check(shown, machines...); !ok { + shown = "its root" + } + headline := fmt.Sprintf("Let the mesh keep library %s?", t.Library) + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Let the mesh keep a library?" + } + explanation := takeWords(t.Library, shown, t.Node) + "." + if len(t.Modules) > 0 { + explanation += " Waiting on it: " + strings.Join(t.Modules, ", ") + "." + } + 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 library waits", About: "take." + t.Library} + 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 library's data joins its group, and is kept from then on without asking"}) + } + return q, options +} + +// takeOnWarrant performs a take the operator approved: the engine is asked, and the library recorded taken on its +// word. It answers what the engine said. +type takeOnWarrant func(ctx context.Context, t libraryTake, askID, by string) (string, error) + +// askFolders asks the operator about every library waiting to be taken 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 library no longer waits to be taken there"); 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 library (§7) + } + 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 the library %s: %v", t.Library, 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 the library %s: %v", t.Library, err) + continue + } + openNow++ + a.logf("asked the operator to let the mesh keep the library %s on %s (%s)", t.Library, t.Node, 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 — and the library still waits on the filesystem the operator was +// asked about; 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 library on a warrant") + } + // **The library must still wait where the operator was asked about it** (the review of e1c2b744): a take is about + // no condition, so nothing else rechecks it. A library that moved to another home or filesystem, or no longer + // waits, is not taken: the ask is spent, and one for where it waits now is asked. + if a.folders != nil { + waiting, err := a.folders(ctx) + if err != nil { + return "", fmt.Errorf("whether the library %s still waits could not be read, so nothing is taken: %w", + t.Library, err) + } + still := false + for _, now := range waiting { + still = still || now.key() == t.key() + } + if !still { + return "nothing: the library no longer waits to be taken where the operator was asked about it (it " + + "moved, or was taken 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 home's engine is asked with the mesh's key, +// naming the library and the ask the warrant answered; on its word that it started, the library 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 libraryTake, 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: t.Root, By: short, + Access: libraryAccess + t.Library, Warrant: askID}) + if err != nil { + return "", err + } + if answer.Refused != "" { + return "", fmt.Errorf("%s refused to take the library %s: %s", t.Node, t.Library, answer.Refused) + } + if err := open.inventory.RecordTaken(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node, + Placed: t.Placed, By: by, Ask: askID, Warrant: askID, Said: answer.Said}); err != nil { + return "", fmt.Errorf("%s started the take of %s (%s), and it could NOT be recorded: %w", t.Node, t.Library, + answer.Said, err) + } + return fmt.Sprintf("%s takes the library %s: %s", t.Node, t.Library, answer.Said), nil + } +} + +// waitingFolder is one library wait excused, as foldersWaiting gathers them. +type waitingFolder struct { + module, machine string + // placed is the filesystem the library's root is on, as its home's engine names it without a path; empty from + // another machine. + placed string +} + +// foldersWaiting is every library waiting to be taken across the mesh (ADR 0328 §7, §8): from each machine's newest +// statement, each wait of reason keep that is excused (checkWaiting), by library. +func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) ( + map[string][]waitingFolder, operatorWaitFacts) { + var f operatorWaitFacts + out := 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 || w.Reason == link.WaitDisk { + continue + } + lib, isLibrary := strings.CutPrefix(w.Access, libraryAccess) + if !isLibrary { + lib = catalogue.LibraryOfEntry(f.manifests[r.Module], w.Access) + } + if lib == "" { + continue + } + placed := "" + if l := f.libraries[lib]; l.Home == machine { + placed = w.Placed + } + out[lib] = append(out[lib], waitingFolder{module: r.Module, machine: machine, placed: placed}) + } + } + } + return out, f +} + +// foldersIn is the asker's folders on this controller's stores: every library waiting to be taken, with its home, its +// root and the modules waiting on it. +func foldersIn(open *stores) func(ctx context.Context) ([]libraryTake, error) { + return func(ctx context.Context) ([]libraryTake, error) { + healths, err := open.inventory.Healths(ctx) + if err != nil { + return nil, err + } + waiting, f := foldersWaiting(ctx, open.inventory, healths) + return takesOf(waiting, f.libraries), nil + } +} + +// takesOf is one take per library and filesystem from the waits gathered, sorted. A library no longer defined is +// asked about nowhere. +func takesOf(waiting map[string][]waitingFolder, libraries map[string]catalogue.Library) []libraryTake { + var out []libraryTake + for lib, ws := range waiting { + l, defined := libraries[lib] + if !defined { + continue + } + // The home's word on the filesystem stands; waits from other machines name none. + placed := map[string]bool{} + for _, w := range ws { + if w.placed != "" { + placed[w.placed] = true + } + } + if len(placed) == 0 { + placed[""] = true + } + for p := range placed { + t := libraryTake{Library: lib, Node: l.Home, Root: l.Root, Placed: p} + seen := map[string]bool{} + for _, w := range ws { + if w.module != "" && !seen[w.module] { + seen[w.module] = true + t.Modules = append(t.Modules, w.module) + } + } + sort.Strings(t.Modules) + 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_libraries_test.go b/cmd/mesh-controller/kept_libraries_test.go new file mode 100644 index 00000000..27732ca3 --- /dev/null +++ b/cmd/mesh-controller/kept_libraries_test.go @@ -0,0 +1,420 @@ +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 0328 §7, §8 and §9 on the controller's side: a library's wait is excused only for a defined library +// that reaches the machine and is not yet taken, or 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 library, never +// about one taken; and the library is taken only on the warrant, where it still waits. + +var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1", + Libraries: []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}} + +var plexManifest = catalogue.Manifest{Module: "plex", Version: "1", + Libraries: []catalogue.LibraryUse{{ID: "films", Library: "media", Path: "movies", Use: catalogue.AccessRead}}} + +var mediaLib = catalogue.Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}} + +func keepWait(access string) inventory.Wait { + return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep, + What: "let the mesh keep the library media"} +} + +func placedWait(access, placed string) inventory.Wait { + w := keepWait(access) + w.Placed = placed + 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 media"} +} + +// libraryFacts is the facts with media defined, and each library given taken on ace on fs-000000000001. +func libraryFactsWith(taken ...string) operatorWaitFacts { + t := map[string]inventory.TakenLibrary{} + for _, l := range taken { + t[l] = inventory.TakenLibrary{Library: l, Home: "ace", Placed: "fs-000000000001"} + } + return operatorWaitFacts{ + manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, + given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}}, + libraries: map[string]catalogue.Library{"media": mediaLib}, + taken: t, + librariesRead: true, + } +} + +// Rows 8 and 9: excused for a defined library reaching the machine and not yet taken — by the module's entry or the +// library's own wait — and for one whose disk is not there, taken or not; judged unhealthy when undefined, not +// reaching the machine, already taken on the filesystem it is on, for an access that is not a library, or for a +// reason the controller does not know. +func TestALibraryWaitIsExcusedOnlyForALibraryNotYetTaken(t *testing.T) { + for _, c := range []struct { + name string + machine string + w inventory.Wait + f operatorWaitFacts + says string + }{ + {"the module's entry, not taken", "ace", keepWait("movies"), libraryFactsWith(), ""}, + {"the library's own wait, not taken", "ace", keepWait("library:media"), libraryFactsWith(), ""}, + {"on a machine it reaches", "novox", keepWait("movies"), libraryFactsWith(), ""}, + {"its disk not there", "ace", diskWait("movies"), libraryFactsWith(), ""}, + {"its disk not there, taken before", "ace", diskWait("movies"), libraryFactsWith("media"), ""}, + {"taken already", "ace", keepWait("movies"), libraryFactsWith("media"), "taken already"}, + {"taken on the filesystem it is on", "ace", placedWait("library:media", "fs-000000000001"), + libraryFactsWith("media"), "taken already"}, + {"taken on another filesystem: it moved", "ace", placedWait("library:media", "fs-0000000000ff"), + libraryFactsWith("media"), ""}, + {"a machine it does not reach", "far", keepWait("movies"), libraryFactsWith(), "does not reach far"}, + {"undefined", "ace", keepWait("library:books"), libraryFactsWith(), "not defined"}, + {"not a library", "ace", keepWait("downloads"), libraryFactsWith(), "only a library waits"}, + {"a reason not known", "ace", inventory.Wait{Part: "radarr", Access: "movies", Reason: "later", What: "w"}, + libraryFactsWith(), "take it or for its disk"}, + {"libraries unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: libraryFactsWith().manifests}, + "could not be read"}, + } { + 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 libraryWaiting(module string, w inventory.Wait) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: module, Resource: module + ".app", Kind: "container", Target: module, + State: link.StateWaiting, Waits: []inventory.Wait{w}} +} + +// Rows 8 and 9 at the gate: both excused waits pass the first-node gate as a wait for a person, and the module is said +// as needs-operator in words with no path; a wait on a library taken already does not pass. +func TestALibraryWaitPassesTheGateAndAsksTheOperatorWithoutAPath(t *testing.T) { + now := time.Now() + for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} { + w.What += " at /storage/media" // an engine's words carrying a path never reach the operator's + f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{libraryWaiting("radarr", w)}}}} + h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f) + if h != healthPerson || !strings.Contains(why, "waits for the operator") || strings.Contains(why, "/") { + t.Fatalf("an excused library wait reads %v %q; want a wait for a person, with no path", h, why) + } + o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil) + 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 library entry movies when you are asked" + if w.Disk { + want = "mount the disk that holds the library entry movies on ace" + } + if o.Kind != kindNeedsOperator || !strings.Contains(o.Needs, want) { + t.Errorf("needs-operator %s asks %q; want %q", o.Kind, o.Needs, want) + } + } + f := gateFacts{now: now, waits: libraryFactsWith("media"), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{libraryWaiting("radarr", 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 library reads %v %q", h, why) + } +} + +func takeRig(t *testing.T, waiting func() map[string][]waitingFolder) (*askerRig, *[]string) { + r := newAskerRig(t) + var taken []string + libs := map[string]catalogue.Library{"media": mediaLib} + r.a.folders = func(context.Context) ([]libraryTake, error) { return takesOf(waiting(), libs), nil } + r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil } + r.a.take = func(_ context.Context, lt libraryTake, askID, by string) (string, error) { + taken = append(taken, lt.Library+" "+lt.Node+" "+lt.Root+" "+lt.Placed+" "+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 +} + +func openTakes(r *askerRig) []asked { + var out []asked + for _, a := range takeAsks(r) { + if a.State == askOpen { + out = append(out, a) + } + } + return out +} + +// Row 7: one ask per library however many modules and machines wait on it, in the clause's words, at approve; none +// again while it is open; taken back when the library no longer waits. +func TestTheOperatorIsAskedOncePerLibrary(t *testing.T) { + current := map[string][]waitingFolder{"media": { + {module: "radarr", machine: "ace", placed: "fs-000000000001"}, + {module: "plex", machine: "novox"}, + {module: "", machine: "ace", placed: "fs-000000000001"}}} + r, _ := takeRig(t, func() 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 library", 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 root may not leave the mesh (the router's content rule): it is said as "its root". + want := "Let the mesh keep library media at its root on ace: its group becomes mesh-media, the group may read " + + "and write, folders keep the group; owners and everyone else's rights stay as they are. Waiting on it: plex, radarr." + if q.Explanation != want { + t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want) + } + if kept[0].Take.Node != "ace" || kept[0].Take.Placed != "fs-000000000001" { + t.Errorf("the take is for %+v; want the home and the filesystem its home names", kept[0].Take) + } + 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 library that no longer waits: %+v", got) + } +} + +// Row 7: nothing is taken without the operator's warrant — a warrant for another asker, an ask that expired, or a +// record changed after the ask takes nothing; the warrant takes it once, on its home, naming library:. +func TestALibraryIsTakenOnlyOnTheWarrant(t *testing.T) { + waits := func() map[string][]waitingFolder { + return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: "fs-000000000001"}}} + } + ctx := context.Background() + r, taken := takeRig(t, waits) + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + a := takeAsks(r)[0] + other := warrantOn(a, "let-the-mesh-keep-it", r.now) + other.Asker = "someone-else" + answerWith(t, r, other) + changed := r.store[a.ID] + changed.Take = &libraryTake{Library: "media", Node: "ace", Root: "/", Modules: a.Take.Modules, Placed: a.Take.Placed} + 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) + } + + r2, taken2 := takeRig(t, waits) + if err := r2.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + b := takeAsks(r2)[0] + answerWith(t, r2, asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now}) + if len(*taken2) != 0 { + t.Fatal("taken on an ask that expired") + } + + r3, taken3 := takeRig(t, waits) + 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.HasPrefix((*taken3)[0], "media ace /storage/media fs-000000000001 "+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) + } +} + +// A warrant for a library on one filesystem is never carried out on another: when it comes and the library waits on +// another, 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][]waitingFolder { + return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: placed}}} + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + a := takeAsks(r)[0] + placed = "fs-0000000000ff" + 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 library moved: %s, %q", spent.State, spent.Acted) + } + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + if again := openTakes(r); len(again) != 1 || again[0].Take.Placed != placed { + t.Fatalf("not asked again for the filesystem it is on now: %+v", again) + } +} + +// The take act names library: and the warrant, is signed over its own context, and is recorded only on the +// engine's word. +func TestATakeAskNamesTheLibraryAndItsWarrant(t *testing.T) { + _, key, _ := ed25519.GenerateKey(nil) + signer := keySigner{key} + ask := link.AccessActAsk{Node: "ace", Path: "/storage/media", By: "the operator", Access: "library:media"} + 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"] != "library:media" || + got["path"] != "/storage/media" { + t.Fatalf("the ask carries %v", got) + } + var asked link.AccessActAsk + refusing := takeFolderIn(nil, func(_ context.Context, a link.AccessActAsk) (link.HandOverAnswer, error) { + asked = a + return link.HandOverAnswer{Refused: "the disk is not there"}, nil + }) + if _, err := refusing(context.Background(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"}, + "c01", "the operator"); err == nil { + t.Fatal("a refused take was answered as done") + } + if asked.Access != "library:media" || asked.Node != "ace" || asked.Path != "/storage/media" || asked.Warrant != "c01" { + t.Fatalf("the engine was asked %+v", asked) + } + 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(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"}, + "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 groups, identities and libraries only when composed — for an engine at link.LibraryContract — +// and an older engine's body is what it was. +func TestTheDeclarationSaysItsLibrariesOnlyWhenComposed(t *testing.T) { + older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body() + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"groups", "identities", "libraries"} { + if strings.Contains(string(older), key) { + t.Fatalf("an older engine's body names %s: %s", key, older) + } + } + newer, err := sendable{Resources: []map[string]any{{"id": "a"}}, + Groups: []catalogue.GroupEntry{{Name: "mesh-media", GID: 50010}}, + Identities: []catalogue.IdentityEntry{{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media"}}}, + Libraries: []catalogue.LibraryEntry{{Library: "media", Root: "/storage/media", Group: "mesh-media", GID: 50010, + Home: true, Paths: []string{"movies"}, Runner: "jochen"}}}.Body() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"groups":[{"name":"mesh-media","gid":50010}]`, + `"identities":[{"module":"radarr","name":"mesh-radarr","uid":50020,"gid":50020,"groups":["mesh-media"]}]`, + `"libraries":[{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,` + + `"taken":false,"paths":["movies"],"runner":"jochen"}]`} { + if !strings.Contains(string(newer), want) { + t.Errorf("the body lacks %s: %s", want, newer) + } + } + if link.LibraryContract != 6 { + t.Fatalf("the contract the engine states for ADR 0328 is 6, and this controller reads %d", link.LibraryContract) + } +} + +// The gate: an access or library the engine cannot judge this look, or entries left while a disk was away, of a module +// whose move leaves its accesses and libraries as they were, are not counted; of one whose move changes them — a +// library added, or joining — or is not known, they hold the gate to its bound. +func TestAnUnknownLibraryIsJudgedByWhetherTheMoveChangesIt(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + unknown := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.library-movies", Kind: link.KindAccess, + Target: "/storage/media/movies", State: link.StateUnknown, Since: now} + left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries, + Target: "/storage/media", State: link.StateUnhealthy, Since: since.Add(-time.Hour)} + facts := func(changes map[string]bool, rs ...inventory.ResourceHealth) gateFacts { + return gateFacts{now: now, waits: libraryFactsWith(), 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 library of a move that changes nothing reads %v %q", h, why) + } + 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 changes nothing: %v %q", h, why) + } + for name, changes := range map[string]map[string]bool{"changes them": {"radarr": true}, "not known": nil} { + for _, r := range []inventory.ResourceHealth{unknown, left} { + if h, _ := moduleHealthWord("radarr", "ace", since, facts(changes, r)); h != healthNotYet { + t.Errorf("%s %s of a move that %s reads %v; want held to the bound", r.Kind, r.State, name, h) + } + } + } + plain := catalogue.Manifest{Module: "radarr"} + if sameAccessUse(plain, radarrManifest) || !sameAccessUse(radarrManifest, radarrManifest) { + t.Fatal("a move that adds a library, and joins, reads unchanged — or an unchanged one reads changed") + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index f0216f1d..b3cfaf8b 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -81,11 +81,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke // 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}) + Placed: w.Placed, Reason: w.Reason, What: w.What}) } resources = append(resources, kept) } @@ -774,14 +770,6 @@ 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 68ec3959..9929b2e0 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -487,15 +487,6 @@ 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 0f8f0672..cfebe241 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -44,16 +44,19 @@ 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 + // libraries is every library the operator defined, and taken every one taken (novox/hq ADR 0328 §7, §8); + // librariesRead says both were read — when not, no access wait is excused. + libraries map[string]catalogue.Library + taken map[string]inventory.TakenLibrary + librariesRead bool } // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) error { + // A library's own wait names it, and no module's manifest (novox/hq ADR 0328 §7). + if lib, ok := strings.CutPrefix(w.Access, libraryAccess); ok && w.Secret == "" && w.Setting == "" { + return checkLibraryWait(machine, lib, w, f) + } m, known := f.manifests[module] 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) @@ -94,49 +97,62 @@ 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. +// libraryAccess prefixes the access a library's own wait and take name: "library:". +const libraryAccess = "library:" + +// checkAccessWait is nil when a module's access wait is excused (novox/hq ADR 0328 §8, §9): the access is one of its +// library entries, and its library's wait is excused. Any other access waits for nothing the operator gives: an +// access that is not a library keeps ADR 0051's meaning. 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 + lib := catalogue.LibraryOfEntry(m, w.Access) + if lib == "" { + return fmt.Errorf("says it waits for %s, which is no library entry %s declares: only a library waits for "+ + "the operator (novox/hq ADR 0328 §8)", w.Access, module) } - 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) + return checkLibraryWait(machine, lib, w, f) +} + +// checkLibraryWait is nil when a wait on a library is excused (novox/hq ADR 0328 §8, §9): the library is defined and +// reaches the machine, and either its disk is not there (reason disk) or it has not yet been taken on its home, on the +// filesystem the wait names (reason keep). Any other is judged unhealthy, saying why: a module that uses a library +// already taken is never asked about it. +func checkLibraryWait(machine, lib string, w inventory.Wait, f operatorWaitFacts) error { + if !f.librariesRead { + return fmt.Errorf("says it waits for the library %s, and the libraries could not be read", lib) } + l, defined := f.libraries[lib] switch { + case !defined: + return fmt.Errorf("says it waits for the library %s, which is not defined", lib) + case !l.Reaches(machine): + return fmt.Errorf("says it waits for the library %s, which does not reach %s", lib, machine) 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) + case w.Reason != link.WaitKeep: + return fmt.Errorf("says it waits for the library %s for %q, and a library waits for the operator's word to "+ + "take it or for its disk", lib, w.Reason) } - 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) + // Taken on another home or filesystem (it moved) is not taken by that warrant: excused, and asked again (§7). + // Only the home's engine names the root's filesystem; another machine's wait names its mount, and says nothing of it. + placed := w.Placed + if machine != l.Home { + placed = "" } - // 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) + if t, was := f.taken[lib]; was && t.Home == l.Home && (placed == "" || t.Placed == "" || t.Placed == placed) { + return fmt.Errorf("says it waits for the library %s, which is taken already: a module that uses a taken "+ + "library is never asked about it", lib) } return nil } +// accessWords is an access a wait names, in words with no path: "the library media", or "its library entry movies". +func accessWords(access string) string { + if lib, ok := strings.CutPrefix(access, libraryAccess); ok { + return "the library " + lib + } + return "the library entry " + access +} + // 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" @@ -211,9 +227,9 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string { // 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)) + part = fmt.Sprintf("the disk that holds %s mounted (%s", accessWords(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, + part = fmt.Sprintf("leave to keep %s (%s, asked of you once for the library", accessWords(w.Access), waitNames(w)) } if w.Secret != "" { @@ -251,9 +267,9 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words { 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) + act = fmt.Sprintf("mount the disk that holds %s on %s", accessWords(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) + act = fmt.Sprintf("let the mesh keep %s when you are asked", accessWords(w.Access)) } if act != "" && !seen[act] { seen[act] = true @@ -291,12 +307,7 @@ 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 } @@ -318,20 +329,6 @@ 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 } @@ -339,13 +336,12 @@ 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 + // The libraries and which are taken (ADR 0328 §7, §8): read once. + if !f.librariesRead { + libs, lerr := inv.Libraries(ctx) + taken, terr := inv.TakenLibraries(ctx) + if lerr == nil && terr == nil { + f.libraries, f.taken, f.librariesRead = libs, taken, true } } } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 7a84b392..a5d6630d 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -448,7 +448,8 @@ func declarationWith(ctx context.Context, open *stores, node string, if err != nil { return sendable{}, err } - out := sendable{Resources: composed.Resources, Adoption: adoption, Identities: composed.Identities, + out := sendable{Resources: composed.Resources, Adoption: adoption, Groups: composed.Groups, + Identities: composed.Identities, Libraries: composed.Libraries, 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, @@ -976,18 +977,17 @@ 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) + // The libraries, the numbers of the modules that join and of the libraries' groups, and which libraries are taken + // (novox/hq ADR 0328), to an engine that keeps libraries. + lf, err := libraryFacts(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, + ReadsHealth: readsHealth, + JudgesRoot: judgesRoot, + KeepsLibraries: lf.keeps, Identities: lf.identities, Libraries: lf.libraries, LibraryGroups: lf.groups, + TakenLibraries: lf.taken, LibraryPaths: lf.paths, Held: lf.held, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, @@ -1020,30 +1020,58 @@ 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) { +// engineKeepsLibraries says whether a machine's node-engine makes the mesh's users and library groups and keeps +// libraries (novox/hq ADR 0328), by its own newest statement, for the same reason as engineReadsHealth: an older engine +// parses strictly, and is sent none of it. +func engineKeepsLibraries(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 + return had && stated.Contract >= link.LibraryContract, 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{} +// libraryRendering is what a machine's composition needs of ADR 0328. +type libraryRendering struct { + keeps bool + identities map[string]catalogue.ModuleIdentity + libraries map[string]catalogue.Library + groups map[string]int + taken map[string]bool + paths map[string][]string + held map[string]string +} + +// libraryFacts reads what a machine's composition needs of ADR 0328: the libraries and their group numbers, the +// numbers of the modules here that join, which libraries are taken, the sub-paths every module asks of each, and which +// joining modules here are held at the user they ran as. **A number is allocated only on the send path** (Allocating), +// as a port is: a question about what would be sent reads what was allocated. The libraries are read for every engine, +// so a module that joins is left out, naming why, where its engine cannot be told it does. +func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution, + choosing Choosing) (libraryRendering, error) { + var lf libraryRendering + joining := false for _, m := range plan.Modules { - if !catalogue.DeclaresIdentity(m) { + joining = joining || catalogue.Joins(m) + } + libs, err := inv.Libraries(ctx) + if err != nil { + return lf, err + } + reached := false + for _, l := range libs { + reached = reached || l.Reaches(node) + } + if !joining && !reached { + return lf, nil // nothing here joins, and no library reaches it: composed exactly as before (§14) + } + lf.libraries = libs + if lf.keeps, err = engineKeepsLibraries(ctx, inv, node); err != nil || !lf.keeps { + return lf, err + } + lf.identities = map[string]catalogue.ModuleIdentity{} + for _, m := range plan.Modules { + if !catalogue.Joins(m) { continue } var id catalogue.ModuleIdentity @@ -1054,43 +1082,87 @@ func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, pla id, had, err = inv.IdentityOf(ctx, m.Module) } if err != nil { - return false, nil, nil, nil, err + return lf, err } if had { - identities[m.Module] = id + lf.identities[m.Module] = id } } - taken, err := inv.KeptFolders(ctx, node) - if err != nil { - return false, nil, nil, nil, err + if lf.groups, err = inv.LibraryGroups(ctx); err != nil { + return lf, 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 + if choosing == Allocating { + for name, l := range libs { + if _, has := lf.groups[name]; has || !l.Reaches(node) { + continue + } + if lf.groups[name], err = inv.AllocateLibraryGroup(ctx, name); err != nil { + return lf, err } } - 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 + } + if lf.paths, err = inv.LibraryPaths(ctx); err != nil { + return lf, err + } + if lf.taken, err = librariesTaken(ctx, inv, libs); err != nil { + return lf, err + } + // **A joining module that ran here before keeps the user it ran as until every library it uses is taken** (§8): + // held, it is left out of the declaration, its containers untouched. One new here has nothing to keep. + sent, known, err := inv.SentBuilds(ctx, node) + if err != nil { + return lf, err + } + for _, m := range plan.Modules { + if _, ran := sent[m.Module]; !known || !ran || !catalogue.Joins(m) { + continue + } + for _, u := range m.Libraries { + if !lf.taken[u.Library] { + if lf.held == nil { + lf.held = map[string]string{} + } + lf.held[m.Module] = u.Library + break + } + } + } + return lf, nil +} + +// librariesTaken is which libraries are taken now (§7): recorded taken on the home they have, on the filesystem their +// home's newest statement names — or stated kept by their home's engine, which keeps one it found empty without asking. +func librariesTaken(ctx context.Context, inv *inventory.Inventory, libs map[string]catalogue.Library) (map[string]bool, error) { + recorded, err := inv.TakenLibraries(ctx) + if err != nil { + return nil, err + } + out := map[string]bool{} + for name, l := range libs { + t, was := recorded[name] + taken := was && t.Home == l.Home + stated, had, err := inv.HealthOf(ctx, l.Home) + if err != nil { + return nil, err + } + if had { + for _, r := range stated.Resources { + kept := (r.Resource == libraryAccess+name || filepath.Clean(r.Target) == l.Root) && + (r.Kind == link.KindAccess || r.Kind == link.KindLibrary) + if kept && r.State == link.StateHealthy { + taken = true + } + for _, w := range r.Waits { + if w.Access == libraryAccess+name && w.Reason == link.WaitKeep && w.Placed != "" && was && + t.Placed != "" && w.Placed != t.Placed { + taken = false // it moved to another filesystem: not taken there until its new warrant + } } } } + out[name] = taken } - return true, identities, kept, ready, nil + return out, nil } // zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 20385898..5b5a577b 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -18,9 +18,12 @@ 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 + // Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR + // 0328): composed only for an engine that keeps libraries, and not sent at all when empty — an older engine + // refuses a key it does not know, whole. + Groups []catalogue.GroupEntry + Identities []catalogue.IdentityEntry + Libraries []catalogue.LibraryEntry // 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. @@ -113,9 +116,15 @@ func (s sendable) Body() ([]byte, error) { if s.Generation > 0 { envelope["generation"] = s.Generation } + if len(s.Groups) > 0 { + envelope["groups"] = s.Groups + } if len(s.Identities) > 0 { envelope["identities"] = s.Identities } + if len(s.Libraries) > 0 { + envelope["libraries"] = s.Libraries + } // 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/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index abe22e32..e29af6b7 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -247,19 +247,23 @@ type Rendering struct { // 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. + // KeepsLibraries says this machine's node-engine makes the mesh's users and library groups and keeps libraries + // (novox/hq ADR 0328: its statement's contract is link.LibraryContract or later). To an older, strict engine none + // of it is sent, and a module that joins is left out of its declaration, naming why. + KeepsLibraries bool + // Identities is each joining module's allocated number, by module (ADR 0328 §4): the same on every machine. A + // joining module with none here is left out, as at a question asked before its first send. 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 + // Libraries is every library the operator defined, by name (§1); LibraryGroups each one's allocated group number + // (§3); TakenLibraries those whose data the operator let the mesh take on their home, on the filesystem it is on + // now (§7); LibraryPaths every sub-path any module in the mesh asks of each, kept on its home (§6). + Libraries map[string]Library + LibraryGroups map[string]int + TakenLibraries map[string]bool + LibraryPaths map[string][]string + // Held is, by module, the library a joining module that ran here before waits on: until every library it uses + // is taken its containers keep the user they ran as, so it is left out of the declaration (§8). + Held map[string]string } // RootField is a user resource's field saying the account must never become root without a person @@ -334,9 +338,11 @@ 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 + // Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR + // 0328): composed only for an engine that keeps libraries, and sent only when not empty. + Groups []GroupEntry + Identities []IdentityEntry + Libraries []LibraryEntry } // ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of @@ -399,8 +405,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { received := map[string]map[string][]Contribution{} leftOut := map[string]string{} var foreseen, unplaced []string - var identities []ModuleIdentity - resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities) + var joined []Joined + resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined) if err != nil { return Composed{}, err } @@ -416,9 +422,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { sort.Strings(unplaced) composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, Foreseen: foreseen, Unplaced: unplaced} - if len(identities) > 0 { - composed.Identities = identitiesOf(identities) - } + composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined) return composed, nil } @@ -430,13 +434,26 @@ 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, identities *[]ModuleIdentity) ([]map[string]any, error) { + foreseen, unplaced *[]string, joinedOut *[]Joined) ([]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 // containers untouched, the machine told so by name — and the machine is told everything else. // Before placing, because a placement is a setting too. left := r.LeftOut(with.Settings, with.Adopted) + // And a module whose libraries cannot be composed here, or that waits for one to be taken (novox/hq ADR 0328 §2, + // §8): left out, naming why, its containers untouched — so a container keeps the user it ran as. + for _, m := range r.Modules { + if _, already := left[m.Module]; already { + continue + } + if why := libraryLeftOut(m, r.Node, with); why != "" { + if left == nil { + left = map[string]string{} + } + left[m.Module] = why + } + } kept := make([]Manifest, 0, len(r.Modules)) var stillBackedUp []Manifest for _, m := range r.Modules { @@ -577,18 +594,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 + // Its identity, where it joins (novox/hq ADR 0328 §4): its user and groups, and where its libraries are here. + var joined Joined + joins := Joins(m) + var libPaths map[string]string + if joins { + if joined, err = joinedOf(m, with); err != nil { + return nil, err + } + libPaths = libraryPaths(m, with) + if joinedOut != nil { + *joinedOut = append(*joinedOut, joined) + } } - 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. // @@ -1040,10 +1058,12 @@ 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 + // And its identity and libraries, where it joins (novox/hq ADR 0328 §4, §5, §10): after the placement's + // owner, which a joining module may not set. + if joins { + if err := joinedInto(copied, m, joined, libPaths); 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 @@ -1162,12 +1182,6 @@ 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/libraries.go b/internal/catalogue/libraries.go new file mode 100644 index 00000000..ec9275d1 --- /dev/null +++ b/internal/catalogue/libraries.go @@ -0,0 +1,839 @@ +package catalogue + +import ( + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Libraries, and the modules that join them (novox/hq ADR 0328). +// +// **A library is a named tree of shared data with one home** (§1). The operator defines it — its name, the machine it +// lives on, its root there and the machines it reaches — as the mesh-wide `libraries` setting of the controller's own +// module, a trusted setting set at the controller's terminal or on the operator's Approve of a proposal (ADR 0277), +// never through a verb alone. Libraries never overlap on a machine (§13), and a library never shares a name with a +// module (§4), so `mesh-` and `mesh-` never collide. +// +// **A module asks for a library by name, sub-path and use** (§2): a manifest's `libraries` entries {id, library, +// path, use}. On each machine an entry resolves to the library's root joined with the path; a module assigned to a +// machine the library does not reach is left out there, naming the library. `${library:}` is that host path, and +// is filled only where a host path belongs (§5): a container's volume source, and a word of the module's tools, which +// run on the machine. A `read` entry is mounted read-only (§10). +// +// **A module joins by using the model** (§14): it declares `libraries` or uses `${identity:…}`. It is given one number +// from the mesh's range, as its user's and its own group's (§4); its user's primary group is the group of the first +// library it uses, the others are added groups, and with no library its own group is primary. `${identity:user}`, +// `${identity:uid}`, `${identity:group}` and `${identity:gid}` are filled wherever its resources take values (§5), and +// its directories are owned by its user and primary group (§4). A module that does neither is composed exactly as +// before. +// +// **The range is 50000–59999** for every number the mesh allocates — a joining module's and a library's group — from +// one sequence, so a module's own group and a library's never share a number. It lies above the numbers distributions +// give people and below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a root group's (0, adm's 4, +// wheel's 10): no allocated number is root's (§15). The store refuses a row outside it. +// +// **Only to an engine that reads it** (Rendering.KeepsLibraries: its statement's contract is link.LibraryContract or +// later), the declaration carries `groups`, `identities` and `libraries` (libraryEnvelope). To an older engine a module +// that joins is left out of the declaration, naming why — its placeholders could not be filled — and everything else is +// composed as before. + +// The range the mesh allocates user and group numbers from (novox/hq ADR 0328 §3, §4, §15). The store holds the same +// bounds. +const ( + IdentityLow = 50000 + IdentityHigh = 59999 +) + +// AllocatableID says whether n is a number the mesh may give: inside the range, so never 0 nor a root group's. +func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh } + +// ADR0328 is how the checks name the decision their rules come from. +const ADR0328 = "novox/hq ADR 0328" + +// MeshName is the user or group a module or a library is on every machine: mesh-. +func MeshName(name string) string { return "mesh-" + name } + +// IdentityName is the user a joining module runs as, and its own group. +func IdentityName(module string) string { return MeshName(module) } + +// ---- libraries, as the operator defines them ----------------------------------------------------------------- + +// LibrariesSetting is the key of the controller's mesh-wide settings layer that defines the libraries (§1). +const LibrariesSetting = "libraries" + +// LibrariesModule is the module whose mesh-wide layer holds LibrariesSetting: the controller's own. +const LibrariesModule = "mesh-controller" + +// Library is one library as the operator defined it. +type Library struct { + Name string `json:"-"` + // Home is the machine the data lives on; Root its path there, the same on every machine it reaches. + Home string `json:"home"` + Root string `json:"root"` + // Reach is every machine the library reaches, its home among them. + Reach []string `json:"reach,omitempty"` +} + +// Reaches says whether the library reaches a machine: its home, or one it names. +func (l Library) Reaches(node string) bool { return node == l.Home || hasName(l.Reach, node) } + +// Machines is every machine the library reaches, its home first. +func (l Library) Machines() []string { + out := []string{l.Home} + for _, n := range l.Reach { + if n != l.Home && !hasName(out, n) { + out = append(out, n) + } + } + return out +} + +// Group is the library's group: mesh-. +func (l Library) Group() string { return MeshName(l.Name) } + +var libraryName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// ParseLibraries reads the `libraries` setting: {: {home, root, reach}}, or refuses it in words. +func ParseLibraries(v any) (map[string]Library, error) { + if v == nil { + return nil, nil + } + blocks, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s is a { : {home, root, reach} } map (%s §1)", LibrariesSetting, ADR0328) + } + out := map[string]Library{} + for name, raw := range blocks { + if !libraryName.MatchString(name) { + return nil, fmt.Errorf("the library %q: a library's name is lowercase letters, digits and dashes, starting "+ + "with a letter", name) + } + fields, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("the library %s is {home, root, reach}, not %v", name, raw) + } + l := Library{Name: name} + for k, v := range fields { + switch k { + case "home": + l.Home, _ = v.(string) + case "root": + l.Root, _ = v.(string) + case "reach": + list, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("the library %s's reach is a list of machines", name) + } + for _, n := range list { + s, _ := n.(string) + if s == "" { + return nil, fmt.Errorf("the library %s's reach names a machine that is not a name: %v", name, n) + } + l.Reach = append(l.Reach, s) + } + default: + return nil, fmt.Errorf("the library %s says %q; a library is its home, its root and its reach, and "+ + "nothing about an owner, a group, a mode or a number (%s §1)", name, k, ADR0328) + } + } + if strings.TrimSpace(l.Home) == "" { + return nil, fmt.Errorf("the library %s names no home machine", name) + } + if !strings.HasPrefix(l.Root, "/") { + return nil, fmt.Errorf("the library %s's root %q is not an absolute path", name, l.Root) + } + l.Root = filepath.Clean(l.Root) + if why := systemPath(l.Root); why != "" { + return nil, fmt.Errorf("the library %s's root is %s: %s, and the node-engine keeps a library's root in its "+ + "group — no library is ever there", name, l.Root, why) + } + sort.Strings(l.Reach) + out[name] = l + } + return out, nil +} + +// LibrariesProblems refuses what no library may be together (§4, §13): a library named as a registered module, and two +// libraries on one machine whose roots lie inside each other — each named. +func LibrariesProblems(libs map[string]Library, modules []string) []string { + var problems []string + names := make([]string, 0, len(libs)) + for n := range libs { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + if hasName(modules, n) { + problems = append(problems, fmt.Sprintf("the library %s has the name of the module %s: %s would be both "+ + "(%s §4)", n, n, MeshName(n), ADR0328)) + } + } + for i, a := range names { + for _, b := range names[i+1:] { + la, lb := libs[a], libs[b] + shared := "" + for _, m := range la.Machines() { + if lb.Reaches(m) { + shared = m + break + } + } + if shared == "" { + continue + } + if within(la.Root, lb.Root) || within(lb.Root, la.Root) { + problems = append(problems, fmt.Sprintf("the libraries %s (%s) and %s (%s) overlap on %s: a library's "+ + "root lies neither inside another's nor around it — divide one library by the paths its modules "+ + "ask for (%s §13)", a, la.Root, b, lb.Root, shared, ADR0328)) + } + } + } + return problems +} + +// within says path is root or lies below it. +func within(path, root string) bool { + return path == root || strings.HasPrefix(path, strings.TrimSuffix(root, "/")+"/") +} + +func hasName(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// ---- a module's use of them --------------------------------------------------------------------------------- + +// LibraryUse is one `libraries` entry of a manifest: the id the module's resources name it by, the library, the +// sub-path within it, and the use. +type LibraryUse struct { + ID string `json:"id"` + Library string `json:"library"` + Path string `json:"path"` + Use string `json:"use"` +} + +// At is the entry's use, read by default. +func (u LibraryUse) At() string { + if u.Use == "" { + return AccessRead + } + return u.Use +} + +// libraryRef is how a module names one of its library entries: ${library:}. +var libraryRef = regexp.MustCompile(`\$\{library:([a-z0-9][a-z0-9-]*)\}`) + +// identityRef is a module's identity by name: ${identity:user|uid|group|gid}. +var identityRef = regexp.MustCompile(`\$\{identity:(user|uid|group|gid)\}`) + +// Joins says a module joins the model (§14): it declares libraries or uses its identity anywhere. +func Joins(m Manifest) bool { + if len(m.Libraries) > 0 { + return true + } + found := false + for _, r := range m.Resources { + walkStrings(r, func(s string) { + found = found || identityRef.MatchString(s) || strings.Contains(s, "${identity:") + }) + } + for _, b := range m.Bundles { + for _, v := range b.Env { + found = found || strings.Contains(v, "${identity:") + } + } + return found +} + +// walkStrings calls see with every string a value holds, however deep. +func walkStrings(v any, see func(string)) { + switch v := v.(type) { + case string: + see(v) + case []any: + for _, x := range v { + walkStrings(x, see) + } + case map[string]any: + for _, x := range v { + walkStrings(x, see) + } + } +} + +// mapStrings is v with every string it holds replaced by fill's answer, however deep; the first error stops it. +func mapStrings(v any, fill func(string) (string, error)) (any, error) { + switch v := v.(type) { + case string: + return fill(v) + case []any: + out := make([]any, len(v)) + for i, x := range v { + y, err := mapStrings(x, fill) + if err != nil { + return nil, err + } + out[i] = y + } + return out, nil + case map[string]any: + out := make(map[string]any, len(v)) + for k, x := range v { + y, err := mapStrings(x, fill) + if err != nil { + return nil, err + } + out[k] = y + } + return out, nil + } + return v, nil +} + +var libraryEntryID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// numberWords are the setting names that carry a user or group number or an owner: none for a module that joins (§5). +// "user" and "group" are left out: an application's own login or group name is a setting like any other. +var numberWords = map[string]bool{"uid": true, "gid": true, "puid": true, "pgid": true, "user-id": true, + "group-id": true, "owner": true} + +// libraryProblems is what the module check refuses of a manifest's libraries and identity (§2, §4, §5, §14): +// +// - an entry that is not {id, library, path, use} with a relative sub-path inside its library, or names a host +// path, an owner, a group, a mode or a number (the entry's shape refuses any other key); +// - two entries with one id, or a `${library:}` naming none; +// - `${library:}` anywhere but a container's volume source (and a word of its tools, a bundle's env); +// - `${identity:…}` other than user, uid, group or gid; +// - in a module that joins: a directory naming its owner. A setting of it naming a number or an owner is refused +// where it is set (JoiningSettingProblem). +func libraryProblems(m Manifest) []string { + var problems []string + ids := map[string]bool{} + for _, u := range m.Libraries { + where := fmt.Sprintf("%s's library entry %q", m.Module, u.ID) + switch { + case !libraryEntryID.MatchString(u.ID): + problems = append(problems, where+": an id is lowercase letters, digits and dashes") + case ids[u.ID]: + problems = append(problems, where+": two entries share the id") + } + ids[u.ID] = true + if !libraryName.MatchString(u.Library) { + problems = append(problems, fmt.Sprintf("%s names the library %q, which is not a library's name", where, + u.Library)) + } + clean := filepath.Clean(u.Path) + if u.Path == "" || strings.HasPrefix(u.Path, "/") || clean == ".." || strings.HasPrefix(clean, "../") || + strings.ContainsAny(u.Path, "${}") { + problems = append(problems, fmt.Sprintf("%s's path %q is a sub-path inside the library, relative and "+ + "staying inside it — never a host path (%s §2)", where, u.Path, ADR0328)) + } + if u.At() != AccessRead && u.At() != AccessReadWrite { + problems = append(problems, fmt.Sprintf("%s is used %q; a use is %q or %q", where, u.Use, AccessRead, + AccessReadWrite)) + } + } + for _, r := range m.Resources { + kind := fmt.Sprint(r["type"]) + for field, v := range r { + walkStrings(v, func(s string) { + for _, tok := range regexp.MustCompile(`\$\{identity:[^}]*\}`).FindAllString(s, -1) { + if !identityRef.MatchString(tok) { + problems = append(problems, fmt.Sprintf("%s's resource %v says %s: the identity is "+ + "${identity:user}, ${identity:uid}, ${identity:group} or ${identity:gid} (%s §5)", m.Module, + r["id"], tok, ADR0328)) + } + } + for _, match := range libraryRef.FindAllStringSubmatch(s, -1) { + if !ids[match[1]] { + problems = append(problems, fmt.Sprintf("%s's resource %v says ${library:%s}, and declares no "+ + "library entry %q", m.Module, r["id"], match[1], match[1])) + } + source := "" + if kind == "container" && field == "volumes" { + source = volumeSource(s) + } + if !strings.Contains(source, match[0]) { + problems = append(problems, fmt.Sprintf("%s's resource %v says %s in its %s: a library's path "+ + "is a host path, filled only in a container's volume source — the process reads the "+ + "container side its volume gives (%s §5)", m.Module, r["id"], match[0], field, ADR0328)) + } + } + }) + } + } + if !Joins(m) { + return problems + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "directory" { + if owner, said := r["owner"]; said { + problems = append(problems, fmt.Sprintf("%s: the directory %v names its owner %v; a module that joins "+ + "owns its directories by its user and primary group, and names no owner (%s §4)", m.Module, + r["id"], owner, ADR0328)) + } + } + } + return problems +} + +// LibraryWarnings is what the module check warns of a module's libraries (§4): an image that drops added groups +// reaches only the first library the module uses. +func LibraryWarnings(m Manifest) []string { + seen := map[string]bool{} + var libs []string + for _, u := range m.Libraries { + if !seen[u.Library] { + seen[u.Library] = true + libs = append(libs, u.Library) + } + } + if len(libs) < 2 { + return nil + } + return []string{fmt.Sprintf("%s uses the libraries %s: its user's primary group is %s, and %s are added groups — an "+ + "image that drops added groups when it changes user reaches only %s (%s §4)", m.Module, strings.Join(libs, ", "), + MeshName(libs[0]), strings.Join(meshNames(libs[1:]), ", "), libs[0], ADR0328)} +} + +func meshNames(names []string) []string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = MeshName(n) + } + return out +} + +// JoiningSettingProblem refuses, where a setting is set, a layer of a module that joins that carries a user or group +// number or an owner (§5): a key whose name says so, or an owner a `places` setting names — its directories are owned +// by its user and primary group, and it reads its own as ${identity:…}. +func JoiningSettingProblem(m Manifest, layers []Layer) error { + if !Joins(m) { + return nil + } + for _, l := range layers { + for k := range l.Values { + if numberWords[k] { + return fmt.Errorf("%s joins, and the layer %q sets %q: no setting of a module that joins carries a user "+ + "or group number or an owner — it reads its own as ${identity:…} (%s §5)", m.Module, l.From, k, ADR0328) + } + } + } + return PlacesOwnerProblem(m, layers) +} + +// PlacesOwnerProblem refuses an owner a `places` setting names for a module that joins (§4, §5): its directories are +// owned by its user and primary group. +func PlacesOwnerProblem(m Manifest, layers []Layer) error { + if !Joins(m) { + return nil + } + placed, err := Places(m, layers) + if err != nil { + return nil // refused by name where it is judged + } + var owned []string + for id, p := range placed { + if p.Owner != "" { + owned = append(owned, id) + } + } + if len(owned) == 0 { + return nil + } + sort.Strings(owned) + return fmt.Errorf("%s places %s with an owner, and it joins: its directories are owned by its user and primary "+ + "group, and no setting of it carries an owner (%s §4, §5)", m.Module, strings.Join(owned, ", "), ADR0328) +} + +// ---- composition ------------------------------------------------------------------------------------------------ + +// ModuleIdentity is a joining module's allocated number: its user's and its own group's. +type ModuleIdentity struct { + Module string + UID int +} + +// Joined is a joining module's identity on a machine, as its placeholders are filled and its directories owned. +type Joined struct { + Module string + UID int + // Primary is its user's primary group, by name and number; Added its other groups, by name, in the order used. + Primary string + PrimaryGID int + Added []string +} + +// librariesOf is the libraries a module uses, by name, in the order its entries first name them. +func librariesOf(m Manifest) []string { + var out []string + for _, u := range m.Libraries { + if !hasName(out, u.Library) { + out = append(out, u.Library) + } + } + return out +} + +// joinedOf is module m's identity here: its number, and its groups from the libraries it uses. Refused, naming +// what, where a number is missing or outside the range (§15). +func joinedOf(m Manifest, with Rendering) (Joined, error) { + id, has := with.Identities[m.Module] + if !has { + return Joined{}, fmt.Errorf("%s joins, and has no number yet: it is allocated at its first send", m.Module) + } + if !AllocatableID(id.UID) { + return Joined{}, fmt.Errorf("%s's number is %d, outside %d–%d: no number the mesh gives is root's or a root "+ + "group's, and none is sent (%s §15)", m.Module, id.UID, IdentityLow, IdentityHigh, ADR0328) + } + j := Joined{Module: m.Module, UID: id.UID, Primary: IdentityName(m.Module), PrimaryGID: id.UID} + for i, lib := range librariesOf(m) { + gid, has := with.LibraryGroups[lib] + if !has || !AllocatableID(gid) { + return Joined{}, fmt.Errorf("%s uses the library %s, whose group has no number the mesh may give (%s §3, "+ + "§15)", m.Module, lib, ADR0328) + } + if i == 0 { + j.Primary, j.PrimaryGID = MeshName(lib), gid + continue + } + j.Added = append(j.Added, MeshName(lib)) + } + return j, nil +} + +// libraryLeftOut is why module m is left out of this machine's declaration for its libraries, or "": a library it +// uses not defined or not reaching the machine (§2), an engine that cannot be told it joins, or a container held at +// the user it ran as until every library it uses is taken (§8). +func libraryLeftOut(m Manifest, node string, with Rendering) string { + if !Joins(m) { + return "" + } + if !with.KeepsLibraries { + return fmt.Sprintf("%s joins the mesh's users and libraries (%s), and the node-engine on %s is older than "+ + "them: it is left out until that engine is updated", m.Module, ADR0328, node) + } + for _, lib := range librariesOf(m) { + l, defined := with.Libraries[lib] + switch { + case !defined: + return fmt.Sprintf("%s uses the library %s, which is not defined: the operator defines it (%s §1)", + m.Module, lib, ADR0328) + case !l.Reaches(node): + return fmt.Sprintf("%s uses the library %s, which does not reach %s (%s §2)", m.Module, lib, node, ADR0328) + } + } + if _, has := with.Identities[m.Module]; !has { + return fmt.Sprintf("%s joins, and its number is allocated at its first send", m.Module) + } + if lib := with.Held[m.Module]; lib != "" { + return fmt.Sprintf("%s keeps the user it ran as until the library %s is taken: the operator is asked once "+ + "(%s §8)", m.Module, lib, ADR0328) + } + return "" +} + +// libraryPaths is where each of module m's library entries is on this machine: its library's root joined with the +// entry's path, by entry id. +func libraryPaths(m Manifest, with Rendering) map[string]string { + out := map[string]string{} + for _, u := range m.Libraries { + if l, ok := with.Libraries[u.Library]; ok { + out[u.ID] = filepath.Join(l.Root, filepath.Clean(u.Path)) + } + } + return out +} + +// identityFill fills ${identity:…} in one string. +func identityFill(s string, j Joined) string { + return identityRef.ReplaceAllStringFunc(s, func(tok string) string { + switch identityRef.FindStringSubmatch(tok)[1] { + case "user": + return IdentityName(j.Module) + case "uid": + return fmt.Sprint(j.UID) + case "group": + return j.Primary + default: + return fmt.Sprint(j.PrimaryGID) + } + }) +} + +// joinedInto composes one resource of a joining module (§4, §5, §10): ${identity:…} filled everywhere, a container's +// ${library:} volume sources filled with the host path and a read entry mounted read-only, and a directory owned by +// the module's user and primary group. +func joinedInto(resource map[string]any, m Manifest, j Joined, paths map[string]string) error { + for k, v := range resource { + if k == "id" || k == "type" { + continue + } + filled, err := mapStrings(v, func(s string) (string, error) { return identityFill(s, j), nil }) + if err != nil { + return err + } + resource[k] = filled + } + if fmt.Sprint(resource["type"]) == "directory" { + resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.PrimaryGID) + } + if fmt.Sprint(resource["type"]) != "container" { + return nil + } + volumes, _ := resource["volumes"].([]any) + out := make([]any, len(volumes)) + for i, v := range volumes { + out[i] = v + s, ok := v.(string) + if !ok { + continue + } + source := volumeSource(s) + match := libraryRef.FindStringSubmatch(source) + if match == nil { + continue + } + path, has := paths[match[1]] + if !has { + return fmt.Errorf("%s says ${library:%s}, and has no library entry %q", m.Module, match[1], match[1]) + } + filled := strings.Replace(source, match[0], path, 1) + strings.TrimPrefix(s, source) + for _, u := range m.Libraries { + if u.ID == match[1] && u.At() == AccessRead { + filled = readOnly(filled) + } + } + out[i] = filled + } + if len(volumes) > 0 { + resource["volumes"] = out + } + return nil +} + +// readOnly is a volume mounted read-only: "src:dst" becomes "src:dst:ro", and options gain ro in place of rw. +func readOnly(v string) string { + parts := strings.Split(v, ":") + if len(parts) < 2 { + return v + } + if len(parts) == 2 { + return v + ":ro" + } + opts := strings.Split(parts[len(parts)-1], ",") + kept := []string{"ro"} + for _, o := range opts { + if o != "ro" && o != "rw" && o != "" { + kept = append(kept, o) + } + } + parts[len(parts)-1] = strings.Join(kept, ",") + return strings.Join(parts, ":") +} + +// 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 +} + +// bundleLibraryFill fills a joining module's tool words (a bundle's env): ${identity:…}, and ${library:} with the +// host path, since its tools run on the machine (§5). +func bundleLibraryFill(value string, m Manifest, with Rendering) (string, error) { + if !strings.Contains(value, "${identity:") && !strings.Contains(value, "${library:") { + return value, nil + } + j, err := joinedOf(m, with) + if err != nil { + return "", err + } + paths := libraryPaths(m, with) + var missing error + value = libraryRef.ReplaceAllStringFunc(identityFill(value, j), func(tok string) string { + id := libraryRef.FindStringSubmatch(tok)[1] + p, has := paths[id] + if !has { + missing = fmt.Errorf("%s's tools say ${library:%s}, and it has no library entry %q", m.Module, id, id) + return tok + } + return p + }) + return value, missing +} + +// 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 +} + +// LibraryEntry is one library as a machine's declaration names it (§3, §6, §7, §11). +type LibraryEntry struct { + Library string `json:"library"` + Root string `json:"root"` + Group string `json:"group"` + GID int `json:"gid"` + // Home says this machine is the library's home, where the engine keeps it; Taken that the operator let the mesh + // take its existing data there (§7). + Home bool `json:"home"` + Taken bool `json:"taken"` + // Paths are the sub-paths its modules ask for, relative to its root. + Paths []string `json:"paths"` + // Runner is the tool runner's account where a module whose tools run there uses the library: a member of its + // group (§11). Empty otherwise. + Runner string `json:"runner,omitempty"` +} + +// GroupEntry is one group a machine's declaration names: a library's, made on every machine the library reaches (§3). +type GroupEntry struct { + Name string `json:"name"` + GID int `json:"gid"` +} + +// IdentityEntry is one joining module's user as a machine's declaration names it (§4): its own group has its uid, +// and Groups are its library groups, the primary first. +type IdentityEntry struct { + Module string `json:"module"` + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Groups []string `json:"groups,omitempty"` +} + +// libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library +// that reaches it, each joining module composed here, and each library a module here uses or that has its home here. +func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEntry, []IdentityEntry, []LibraryEntry) { + if !with.KeepsLibraries { + return nil, nil, nil + } + var groups []GroupEntry + for name, l := range with.Libraries { + if gid, has := with.LibraryGroups[name]; has && l.Reaches(r.Node) && AllocatableID(gid) { + groups = append(groups, GroupEntry{Name: l.Group(), GID: gid}) + } + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) + var ids []IdentityEntry + for _, j := range joined { + e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.UID} + if j.Primary != IdentityName(j.Module) { + e.Groups = append([]string{j.Primary}, j.Added...) + } + ids = append(ids, e) + } + sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module }) + used := map[string]map[string]bool{} + runner := map[string]string{} + for _, m := range r.Modules { + if with.Held[m.Module] == "" && libraryLeftOut(m, r.Node, with) != "" { + continue + } + for _, u := range m.Libraries { + if used[u.Library] == nil { + used[u.Library] = map[string]bool{} + } + used[u.Library][filepath.Clean(u.Path)] = true + if a := r.toolsAccount(m, with); a != "" && runner[u.Library] == "" { + runner[u.Library] = a + } + } + } + var libs []LibraryEntry + for name, l := range with.Libraries { + home := l.Home == r.Node + if used[name] == nil && !home { + continue + } + gid, has := with.LibraryGroups[name] + if !has || !l.Reaches(r.Node) { + continue + } + paths := map[string]bool{} + for p := range used[name] { + paths[p] = true + } + if home { + // On its home the engine keeps every sub-path any module asks for, wherever it runs (§6). + for _, p := range with.LibraryPaths[name] { + paths[filepath.Clean(p)] = true + } + } + e := LibraryEntry{Library: name, Root: l.Root, Group: l.Group(), GID: gid, Home: home, + Taken: with.TakenLibraries[name], Paths: []string{}, Runner: runner[name]} + for p := range paths { + e.Paths = append(e.Paths, p) + } + sort.Strings(e.Paths) + libs = append(libs, e) + } + sort.Slice(libs, func(i, j int) bool { return libs[i].Library < libs[j].Library }) + return groups, ids, libs +} + +// LibraryUseShape is a module's libraries and whether it joins, in a form that compares: a move that changes either +// changes who uses its libraries (the gate's reading of an unknown or a held library). +func LibraryUseShape(m Manifest) string { + var out []string + for _, u := range m.Libraries { + out = append(out, u.ID+"\x00"+u.Library+"\x00"+filepath.Clean(u.Path)+"\x00"+u.At()) + } + sort.Strings(out) + return fmt.Sprint(Joins(m), out) +} + +// LibraryOfEntry is the library a module's entry id names, or "". +func LibraryOfEntry(m Manifest, id string) string { + for _, u := range m.Libraries { + if u.ID == id { + return u.Library + } + } + return "" +} + +// 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/libraries_test.go b/internal/catalogue/libraries_test.go new file mode 100644 index 00000000..8152a6bb --- /dev/null +++ b/internal/catalogue/libraries_test.go @@ -0,0 +1,319 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0328 on the controller's side, row by row of its "How it is checked" table where the row is the +// controller's: libraries defined (1, 13), entries resolved (2), groups and numbers (3, 4, 15), the identity and library +// placeholders (5), a read use mounted read-only (10), the tool runner in a library's group (11), a joining module held +// until its libraries are taken (8), and a module that does not join composed exactly as before (14). + +const libraryImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111" + +var mediaLibrary = Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}} + +// aRadarr writes movies and reads books, tells its image its identity by PUID and PGID, and has tools on the runner. +func aRadarr(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "radarr", "tools/index.js") + m.Libraries = []LibraryUse{ + {ID: "movies", Library: "media", Path: "movies", Use: AccessReadWrite}, + {ID: "books", Library: "books", Path: "shelf", Use: AccessRead}, + } + m.Resources = []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "env", "type": "file", "path": "${dir:config}/identity.env", "mode": "0644", + "content": "PUID=${identity:uid}\nPGID=${identity:gid}\nUSER=${identity:user}\nGROUP=${identity:group}\nUMASK=002\n"}, + {"id": "app", "type": "container", "name": "radarr", "image": libraryImage, + "env": map[string]any{"PUID": "${identity:uid}", "PGID": "${identity:gid}"}, + "volumes": []any{"${dir:config}:/config", "${library:movies}:/movies", "${library:books}:/books:z"}}, + } + return m +} + +// aPlain is a module that neither declares libraries nor uses its identity. +func aPlain() Manifest { + return Manifest{Module: "plain", Version: "1", Resources: []map[string]any{ + {"id": "data", "type": "directory", "mode": "0700", "owner": "999:999"}, + {"id": "app", "type": "container", "name": "plain", "image": libraryImage, "volumes": []any{"${dir:data}:/data"}}, + }} +} + +func librariesRendering() Rendering { + books := Library{Name: "books", Home: "ace", Root: "/storage/books"} + return Rendering{KeepsLibraries: true, + Libraries: map[string]Library{"media": mediaLibrary, "books": books}, + LibraryGroups: map[string]int{"media": 50010, "books": 50011}, + Identities: map[string]ModuleIdentity{"radarr": {Module: "radarr", UID: 50020}}, + TakenLibraries: map[string]bool{"media": true, "books": true}, + LibraryPaths: map[string][]string{"media": {"movies", "series"}}, + } +} + +func composeLibraries(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 +} + +// Rows 2, 4, 5, 10: a joining module's identity is filled everywhere, its library entries resolve to root + path in its +// volume sources, a read entry mounts read-only, its directories are owned by its user and primary group — the first +// library it uses — and its other libraries are added groups. +func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}} + composed := composeLibraries(t, r, librariesRendering()) + got := byID(composed.Resources) + + app := got["radarr.app"] + if fmt.Sprint(app["env"]) != fmt.Sprint(map[string]any{"PUID": "50020", "PGID": "50010"}) { + t.Errorf("the container's environment is %v", app["env"]) + } + want := []any{"/var/lib/radarr/config:/config", "/storage/media/movies:/movies", "/storage/books/shelf:/books:ro,z"} + if !reflect.DeepEqual(app["volumes"], want) { + t.Errorf("the volumes are %v; want %v", app["volumes"], want) + } + if content := fmt.Sprint(got["radarr.env"]["content"]); content != + "PUID=50020\nPGID=50010\nUSER=mesh-radarr\nGROUP=mesh-media\nUMASK=002\n" { + t.Errorf("the identity file reads %q", content) + } + if owner := got["radarr.config"]["owner"]; owner != "50020:50010" { + t.Errorf("its directory is owned by %v; want its user and primary group", owner) + } + if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr", + Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media", "mesh-books"}}) { + t.Errorf("the identities are %+v", composed.Identities) + } + if w := LibraryWarnings(aRadarr(t)); len(w) != 1 || !strings.Contains(w[0], "reaches only media") { + t.Errorf("a module of two libraries is warned %q", w) + } +} + +// Rows 3, 7, 11: every library reaching the machine has its group, with its number; each library used here or homed +// here is named with its root, whether this is its home, whether it is taken, the sub-paths kept on its home, and the +// tool runner's account where a module whose tools run there uses it. +func TestTheDeclarationNamesTheLibrariesAndTheirGroups(t *testing.T) { + with := librariesRendering() + with.TakenLibraries = map[string]bool{"media": false, "books": true} + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}} + composed := composeLibraries(t, r, with) + raw, _ := json.Marshal(map[string]any{"groups": composed.Groups, "libraries": composed.Libraries}) + want := `{"groups":[{"name":"mesh-books","gid":50011},{"name":"mesh-media","gid":50010}],` + + `"libraries":[{"library":"books","root":"/storage/books","group":"mesh-books","gid":50011,"home":true,` + + `"taken":true,"paths":["shelf"],"runner":"jochen"},` + + `{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,"taken":false,` + + `"paths":["movies","series"],"runner":"jochen"}]}` + if string(raw) != want { + t.Errorf("the declaration says\n %s\nwant\n %s", raw, want) + } + // On a machine it reaches but is not home: the group, the library as used there, and no sub-path from elsewhere. + elsewhere := composeLibraries(t, Resolution{Node: "novox", Account: "jochen", + Modules: []Manifest{aPlain(), theRuntime(t)}}, with) + raw, _ = json.Marshal(map[string]any{"groups": elsewhere.Groups, "libraries": elsewhere.Libraries}) + if string(raw) != `{"groups":[{"name":"mesh-media","gid":50010}],"libraries":null}` { + t.Errorf("on a machine it only reaches: %s", raw) + } +} + +// Row 2: a module assigned to a machine its library does not reach is left out there, naming the library; one whose +// library is not defined likewise. +func TestALibraryNotReachingTheMachineLeavesItsModuleOut(t *testing.T) { + with := librariesRendering() + with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: 50020} + composed := composeLibraries(t, Resolution{Node: "far", Account: "jochen", + Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with) + if why := composed.LeftOut["radarr"]; !strings.Contains(why, "does not reach far") { + t.Errorf("radarr on a machine media does not reach: %q", why) + } + delete(with.Libraries, "books") + composed = composeLibraries(t, Resolution{Node: "ace", Account: "jochen", + Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with) + if why := composed.LeftOut["radarr"]; !strings.Contains(why, "books, which is not defined") { + t.Errorf("radarr with an undefined library: %q", why) + } +} + +// Row 8: a joining module that ran here before keeps the user it ran as until its libraries are taken — left out, its +// containers untouched — while its libraries are still named, so the engine states the wait and the take can come. +func TestAJoiningModuleIsHeldUntilItsLibrariesAreTaken(t *testing.T) { + with := librariesRendering() + with.TakenLibraries["media"] = false + with.Held = map[string]string{"radarr": "media"} + composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen", + Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with) + if why := composed.LeftOut["radarr"]; !strings.Contains(why, "keeps the user it ran as until the library media is taken") { + t.Fatalf("radarr while media is not taken: %q", why) + } + if byID(composed.Resources)["radarr.app"] != nil { + t.Fatal("a held module's container was sent") + } + found := false + for _, l := range composed.Libraries { + found = found || (l.Library == "media" && !l.Taken) + } + if !found { + t.Fatalf("the library a held module waits on is not named: %+v", composed.Libraries) + } +} + +// Row 14: a module that neither declares libraries nor uses its identity is composed exactly as before, on an engine +// that keeps libraries or not; and a joining module on an older engine is left out, naming why. +func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aPlain(), theRuntime(t)}} + before := composeLibraries(t, r, Rendering{}) + after := composeLibraries(t, r, librariesRendering()) + if !reflect.DeepEqual(before.Resources, after.Resources) { + t.Fatalf("a module that does not join composed otherwise:\n %v\n %v", before.Resources, after.Resources) + } + if Joins(aPlain()) || !Joins(aRadarr(t)) { + t.Fatal("joining is not read from the libraries and the identity") + } + older := librariesRendering() + older.KeepsLibraries = false + composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen", + Modules: []Manifest{aRadarr(t), aPlain(), theRuntime(t)}}, older) + if why := composed.LeftOut["radarr"]; !strings.Contains(why, "older than them") { + t.Fatalf("a joining module on an older engine: %q", why) + } + if len(composed.Groups)+len(composed.Identities)+len(composed.Libraries) > 0 { + t.Fatal("an older engine was sent groups, identities or libraries") + } + if byID(composed.Resources)["plain.app"] == nil { + t.Fatal("a module that does not join was left out beside one that does") + } +} + +// Row 15: no number outside the range is sent, root's above all; the composition refuses, naming why. +func TestNoNumberTheMeshGivesIsRoot(t *testing.T) { + for _, n := range []int{0, 4, 10, IdentityLow - 1, IdentityHigh + 1} { + if AllocatableID(n) { + t.Errorf("%d is allocatable", n) + } + with := librariesRendering() + with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: n} + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}} + if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") { + t.Errorf("a module numbered %d composed: %v", n, err) + } + with = librariesRendering() + with.LibraryGroups["media"] = 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(), "§15") { + t.Errorf("a library group numbered %d composed: %v", n, err) + } + } +} + +func libraryManifest(t *testing.T, extra map[string]any) []byte { + t.Helper() + m := map[string]any{"module": "app", "version": "1", "capabilities": []any{"container-runtime"}, + "libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}}, + "resources": []any{map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage, + "volumes": []any{"${library:movies}:/movies"}}}} + for k, v := range extra { + m[k] = v + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +// Rows 2 and 5 at the module check: library entries are accepted by name, sub-path and use, and refused with a host +// path, an owner, a group, a mode or a number; ${library:} only in a volume source; ${identity:…} only by its four +// names; a joining module names no directory owner and declares no number setting. +func TestTheModuleCheckHoldsLibrariesAndIdentityToTheDecision(t *testing.T) { + if _, err := ParseManifest(libraryManifest(t, nil)); err != nil { + t.Fatalf("a library entry was refused: %v", err) + } + entry := func(extra map[string]any) map[string]any { + e := map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"} + for k, v := range extra { + e[k] = v + } + return map[string]any{"libraries": []any{e}} + } + for name, c := range map[string]struct { + extra map[string]any + want string + }{ + "a host path": {entry(map[string]any{"path": "/storage/media/movies"}), "never a host path"}, + "out of it": {entry(map[string]any{"path": "../other"}), "never a host path"}, + "an owner": {entry(map[string]any{"owner": "1000"}), "owner"}, + "a group": {entry(map[string]any{"group": "media"}), "group"}, + "a mode": {entry(map[string]any{"mode": "0775"}), "mode"}, + "a number": {entry(map[string]any{"gid": 1000}), "gid"}, + "a use": {entry(map[string]any{"use": "write"}), "is used \"write\""}, + "in the env": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container", + "name": "app", "image": libraryImage, "env": map[string]any{"MOVIES": "${library:movies}"}}}}, "volume source"}, + "in a file": {map[string]any{"resources": []any{map[string]any{"id": "f", "type": "file", "path": "/etc/app.conf", + "mode": "0644", "content": "movies=${library:movies}"}}}, "volume source"}, + "on the container side": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container", + "name": "app", "image": libraryImage, "volumes": []any{"/srv:${library:movies}"}}}}, "volume source"}, + "an unknown identity": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container", + "name": "app", "image": libraryImage, "env": map[string]any{"HOME": "${identity:home}"}}}}, "${identity:home}"}, + "a directory owner": {map[string]any{"resources": []any{map[string]any{"id": "d", "type": "directory", + "mode": "0750", "owner": "1000:1000"}}}, "names its owner"}, + } { + _, err := ParseManifest(libraryManifest(t, c.extra)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v; want refused saying %q", name, err, c.want) + } + } + // A module that does not join keeps its directory owner and its settings as they are (§14). + plain := map[string]any{"module": "plain", "version": "1", + "resources": []any{map[string]any{"id": "d", "type": "directory", "mode": "0750", "owner": "1000:1000"}}} + raw, _ := json.Marshal(plain) + if _, err := ParseManifest(raw); err != nil { + t.Errorf("a module that does not join was refused: %v", err) + } + // A setting carrying a number, or a placement's owner, is refused for a joining module, and kept for one that does + // not. + if err := JoiningSettingProblem(aRadarr(t), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err == nil || + !strings.Contains(err.Error(), "carries a user or group number") { + t.Errorf("a joining module's puid setting: %v", err) + } + if err := JoiningSettingProblem(aPlain(), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err != nil { + t.Errorf("a plain module's puid setting: %v", err) + } + placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/srv/x", "owner": "1000:1000"}}}}} + if err := PlacesOwnerProblem(aRadarr(t), placed); err == nil || !strings.Contains(err.Error(), "ADR 0328") { + t.Errorf("a joining module's placed owner: %v", err) + } + if err := PlacesOwnerProblem(aPlain(), placed); err != nil { + t.Errorf("a plain module's placed owner: %v", err) + } +} + +// Row 1: the libraries are the controller's own mesh-wide setting, the terminal's or the operator's Approve alone. +func TestTheLibrariesAreTheOperatorsSetting(t *testing.T) { + controller := Manifest{Module: LibrariesModule, Version: "1"} + keys := TerminalKeys(controller) + if !hasName(keys, LibrariesSetting) { + t.Fatalf("the libraries are not among the terminal's keys: %v", keys) + } + if hasName(TerminalKeys(Manifest{Module: "radarr"}), LibrariesSetting) { + t.Fatal("another module's libraries key is the terminal's") + } + if err := ComposesWith(controller, []Layer{{From: "ace", Values: map[string]any{LibrariesSetting: map[string]any{}}}}, + false); err == nil { + t.Fatal("libraries set on one machine were accepted") + } + if _, err := ParseLibraries(map[string]any{"media": map[string]any{"home": "ace", "root": "/etc/media"}}); err == nil { + t.Fatal("a library in the machine's own tree was accepted") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index c6e3717d..b4a11695 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -536,6 +536,9 @@ type Manifest struct { // `directory` resource, which the mesh creates and owns: an access is mounted and nothing // about it is reconciled, and several modules may name the same one without conflict. Accesses []Access `json:"accesses,omitempty"` + // Libraries are the libraries this module uses, each by name, sub-path and use (novox/hq ADR 0328 §2): where a + // library is, and who may write it, is the operator's definition and the mesh's, never the module's. + Libraries []LibraryUse `json:"libraries,omitempty"` // Computed names something in the control plane that works this module's resources out per // node, instead of them being fixed here. @@ -1860,8 +1863,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)...) + // Its libraries and its identity, and what a module that joins is held to (novox/hq ADR 0328). + problems = append(problems, libraryProblems(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 deleted file mode 100644 index c715fa2c..00000000 --- a/internal/catalogue/module_identity.go +++ /dev/null @@ -1,575 +0,0 @@ -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 deleted file mode 100644 index 0ded5053..00000000 --- a/internal/catalogue/module_identity_test.go +++ /dev/null @@ -1,370 +0,0 @@ -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/runtime.go b/internal/catalogue/runtime.go index 874f7108..1774c7a9 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -410,6 +410,10 @@ func bundleWords(m Manifest, with Rendering) (map[string]string, error) { if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil { return nil, err } + // A joining module's identity and its libraries' host paths: its tools run on the machine (ADR 0328 §5). + if value, err = bundleLibraryFill(value, m, with); err != nil { + return nil, err + } if out == nil { out = map[string]string{} } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index f657ba4c..472102b1 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -337,6 +337,10 @@ func UnusedSettings(m Manifest, layers []Layer) []string { } lands := settingKeysUsedBy(m) + // The libraries are the controller's own mesh-wide setting (novox/hq ADR 0328 §1). + if m.Module == LibrariesModule { + lands[LibrariesSetting] = true + } // A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it // overrides the default, which is how a running module gains a setting without a gap between. for key := range m.Settings { @@ -521,6 +525,22 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { // 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 { + // The libraries, on the controller's own layer (novox/hq ADR 0328 §1): each well formed, and only mesh-wide. + if m.Module == LibrariesModule { + for _, l := range layers { + v, has := l.Values[LibrariesSetting] + if !has { + continue + } + if l.From != MeshWideLayer && !l.Default { + return fmt.Errorf("%s is the mesh's, set on the whole mesh and never on one machine (%s §1)", + LibrariesSetting, ADR0328) + } + if _, err := ParseLibraries(v); err != nil { + return err + } + } + } if err := settingsHoldOneLine(m.Module, layers); err != nil { return err } diff --git a/internal/catalogue/terminal_keys.go b/internal/catalogue/terminal_keys.go index d3cf47aa..21154086 100644 --- a/internal/catalogue/terminal_keys.go +++ b/internal/catalogue/terminal_keys.go @@ -79,6 +79,10 @@ func TerminalKeys(m Manifest) []string { } delete(keys, PlacesSetting) delete(keys, AccessesSetting) + // The libraries say where shared data is and who may write it (novox/hq ADR 0328 §1): the operator's alone. + if m.Module == LibrariesModule { + keys[LibrariesSetting] = true + } rest := make([]string, 0, len(keys)) for k := range keys { rest = append(rest, k) diff --git a/internal/catalogue/unconsumed_placeholder.go b/internal/catalogue/unconsumed_placeholder.go index 5c681f4f..95c12921 100644 --- a/internal/catalogue/unconsumed_placeholder.go +++ b/internal/catalogue/unconsumed_placeholder.go @@ -59,6 +59,12 @@ var fillers = []filler{ {namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}}, {namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, {namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, + // A joining module's identity, wherever its resources take values; a library's host path, only in a container's + // volumes, whose source libraryProblems holds it to (novox/hq ADR 0328 §5). + {namespace: "identity", patterns: []*regexp.Regexp{identityRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file", "run", "command", "user", "name"}}}, + {namespace: "library", patterns: []*regexp.Regexp{libraryRef}, fields: map[string][]string{"container": {"volumes"}}, + why: "a library's path is a host path, filled only in a container's volume source; the process reads the " + + "container side its volume gives (novox/hq ADR 0328 §5)"}, {namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}}, why: "a secret is never filled into an environment variable or any other field: put it in a file the " + "module declares and mount that (novox/hq ADR 0086)"}, diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 652dc5ee..f1145926 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -117,6 +117,10 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr } } } + // A module never takes a defined library's name: mesh- and mesh- never collide (ADR 0328 §4). + if err := i.LibraryNamed(ctx, m.Module); err != nil { + return fmt.Errorf("%s is not registered: %w", m.Module, err) + } raw, err := json.Marshal(m) if err != nil { return err @@ -929,6 +933,10 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va // the mesh-wide layer under it when the layer is one node's, and refuses the first thing that // cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes. func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error { + // The libraries the controller's own layer defines (novox/hq ADR 0328 §1, §4, §13). + if err := i.judgeLibraries(ctx, nodeName, module, values); err != nil { + return err + } m, err := i.declared(ctx, module) if err != nil { return fmt.Errorf("%w: %s", ErrNoSuchModule, module) @@ -987,9 +995,8 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, } 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 { + // A module that joins carries no user or group number or owner in a setting (novox/hq ADR 0328 §5). + if err := catalogue.JoiningSettingProblem(m, layers[len(layers)-1:]); err != nil { return fmt.Errorf("refused: %w", err) } // And a key that reaches nothing, refused here where somebody can still fix the spelling: diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 89e31e93..1824114a 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -36,17 +36,6 @@ 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"` -} - -// 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 @@ -62,10 +51,8 @@ type Wait struct { // 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 is the engine's reason for an access wait: "keep" or "disk". Reason string `json:"reason,omitempty"` - ACL string `json:"acl,omitempty"` What string `json:"what"` } diff --git a/internal/inventory/identities.go b/internal/inventory/identities.go deleted file mode 100644 index 3c971def..00000000 --- a/internal/inventory/identities.go +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 3d979e9f..00000000 --- a/internal/inventory/identities_test.go +++ /dev/null @@ -1,224 +0,0 @@ -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/libraries.go b/internal/inventory/libraries.go new file mode 100644 index 00000000..b86270ff --- /dev/null +++ b/internal/inventory/libraries.go @@ -0,0 +1,267 @@ +package inventory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Joining modules' numbers, library groups, taken libraries and the libraries themselves (novox/hq ADR 0328 §1, §3, +// §4, §7, §15; migration 0096). + +// AllocateIdentity is a joining module's number, allocated now when it has none: one number from the mesh's range, +// mesh-wide, kept for ever, its user's and its own group's. 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("a number is allocated for a module, and none was named") + } + if id, had, err := i.IdentityOf(ctx, module); err != nil || had { + return id, err + } + // 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. + if _, err := i.store.Pool().Exec(ctx, + `insert into module_identity (module, uid) select $1, nextval('mesh_number')::integer + on conflict (module) do nothing`, module); err != nil { + return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given its number: %w", module, err) + } + id, had, err := i.IdentityOf(ctx, module) + if err == nil && !had { + err = fmt.Errorf("%s was given its number and it cannot be read back", module) + } + return id, err +} + +// IdentityOf is a module's number, 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 from module_identity where module = $1`, module).Scan(&id.UID) + 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 joining module's number, by module. +func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) { + rows, err := i.store.Pool().Query(ctx, `select module, uid 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); err != nil { + return nil, err + } + out[id.Module] = id + } + return out, rows.Err() +} + +// AllocateLibraryGroup is a library's group number, allocated now when it has none, from the same sequence as the +// modules' numbers (§3). +func (i *Inventory) AllocateLibraryGroup(ctx context.Context, library string) (int, error) { + if strings.TrimSpace(library) == "" { + return 0, errors.New("a group number is allocated for a library, and none was named") + } + groups, err := i.LibraryGroups(ctx) + if err != nil { + return 0, err + } + if gid, had := groups[library]; had { + return gid, nil + } + if _, err := i.store.Pool().Exec(ctx, + `insert into library_group (library, gid) select $1, nextval('mesh_number')::integer + on conflict (library) do nothing`, library); err != nil { + return 0, fmt.Errorf("the library %s could not be given its group's number: %w", library, err) + } + if groups, err = i.LibraryGroups(ctx); err != nil { + return 0, err + } + gid, has := groups[library] + if !has { + return 0, fmt.Errorf("the library %s was given its group's number and it cannot be read back", library) + } + return gid, nil +} + +// LibraryGroups is every library's group number, by library. +func (i *Inventory) LibraryGroups(ctx context.Context) (map[string]int, error) { + rows, err := i.store.Pool().Query(ctx, `select library, gid from library_group`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var name string + var gid int + if err := rows.Scan(&name, &gid); err != nil { + return nil, err + } + out[name] = gid + } + return out, rows.Err() +} + +// Libraries is every library the operator defined (§1): the mesh-wide `libraries` layer of the controller's module. +// None when it was never set. +func (i *Inventory) Libraries(ctx context.Context) (map[string]catalogue.Library, error) { + var raw []byte + err := i.store.Pool().QueryRow(ctx, + `select values from settings where module = $1 and node is null`, catalogue.LibrariesModule).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + var layer map[string]any + if err := json.Unmarshal(raw, &layer); err != nil { + return nil, err + } + return catalogue.ParseLibraries(layer[catalogue.LibrariesSetting]) +} + +// judgeLibraries is what a layer of the controller's module is held to when it defines libraries (§1, §4, §13): only +// mesh-wide, well formed, no library named as a registered module, none overlapping another on a machine. +func (i *Inventory) judgeLibraries(ctx context.Context, nodeName, module string, values map[string]any) error { + v, defines := values[catalogue.LibrariesSetting] + if module != catalogue.LibrariesModule || !defines { + return nil + } + if nodeName != "" { + return fmt.Errorf("refused: %s is the mesh's, set on the whole mesh and never on one machine (%s §1)", + catalogue.LibrariesSetting, catalogue.ADR0328) + } + libs, err := catalogue.ParseLibraries(v) + if err != nil { + return fmt.Errorf("refused: %w", err) + } + shelf, err := i.Catalogue(ctx) + if err != nil { + return err + } + modules := make([]string, 0, len(shelf)) + for name := range shelf { + modules = append(modules, name) + } + if problems := catalogue.LibrariesProblems(libs, modules); len(problems) > 0 { + return fmt.Errorf("refused: %s", strings.Join(problems, "; ")) + } + return nil +} + +// LibraryNamed refuses a module whose name is a defined library's (§4): mesh- and mesh- never collide. +func (i *Inventory) LibraryNamed(ctx context.Context, module string) error { + libs, err := i.Libraries(ctx) + if err != nil { + return err + } + if _, clash := libs[module]; clash { + return fmt.Errorf("the module %s has the name of the library %s: %s would be both (%s §4)", module, module, + catalogue.MeshName(module), catalogue.ADR0328) + } + return nil +} + +// LibraryPaths is every sub-path a module assigned anywhere asks of each library (§6): what its home keeps. +func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, error) { + shelf, err := i.Catalogue(ctx) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, `select distinct module from assignment`) + if err != nil { + return nil, err + } + defer rows.Close() + seen := map[string]map[string]bool{} + for rows.Next() { + var module string + if err := rows.Scan(&module); err != nil { + return nil, err + } + for _, u := range shelf[module].Libraries { + if seen[u.Library] == nil { + seen[u.Library] = map[string]bool{} + } + seen[u.Library][filepath.Clean(u.Path)] = true + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := map[string][]string{} + for lib, paths := range seen { + for p := range paths { + out[lib] = append(out[lib], p) + } + } + return out, nil +} + +// TakenLibrary is one library the operator let the mesh take, on its home (§7). +type TakenLibrary struct { + Library string + Home string + Placed string + By string + Ask string + Warrant string + Said string +} + +// TakenLibraries is every library taken, by name. +func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary, error) { + rows, err := i.store.Pool().Query(ctx, + `select library, home, placed, taken_by, ask, warrant, said from kept_library`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]TakenLibrary{} + for rows.Next() { + var t TakenLibrary + if err := rows.Scan(&t.Library, &t.Home, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said); err != nil { + return nil, err + } + out[t.Library] = t + } + return out, rows.Err() +} + +// RecordTaken keeps that a library was taken on its home, under the operator's warrant. Once per home and filesystem: +// a second take of the same keeps the first, and one on another home or filesystem (it moved, and the operator was +// asked again) replaces it. +func (i *Inventory) RecordTaken(ctx context.Context, t TakenLibrary) error { + switch { + case t.Library == "" || t.Home == "": + return errors.New("a taken library names itself and its home") + case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "": + return errors.New("a library is taken only on the operator's warrant, and this names none") + } + _, err := i.store.Pool().Exec(ctx, + `insert into kept_library (library, home, placed, taken_by, ask, warrant, said) + values ($1, $2, $3, $4, $5, $6, $7) + on conflict (library) do update + set home = excluded.home, placed = excluded.placed, taken_at = now(), taken_by = excluded.taken_by, + ask = excluded.ask, warrant = excluded.warrant, said = excluded.said + where kept_library.home <> excluded.home or kept_library.placed <> excluded.placed`, + t.Library, t.Home, t.Placed, t.By, t.Ask, t.Warrant, t.Said) + return err +} diff --git a/internal/inventory/libraries_test.go b/internal/inventory/libraries_test.go new file mode 100644 index 00000000..7a3d3f77 --- /dev/null +++ b/internal/inventory/libraries_test.go @@ -0,0 +1,300 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0328 §3, §4, §15: one number per joining module (its user's and its own group's) and one per library's +// group, from one sequence and one range — never 0 or a root group's, never shared between a module and a library, +// never given twice, the same however often asked. +func TestOneNumberPerModuleAndLibraryNeverRoot(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 || again != radarr { + t.Fatalf("radarr was given %+v, then %+v (%v): one number per module", radarr, again, err) + } + media, err := inv.AllocateLibraryGroup(ctx, "media") + if err != nil { + t.Fatal(err) + } + if mediaAgain, err := inv.AllocateLibraryGroup(ctx, "media"); err != nil || mediaAgain != media { + t.Fatalf("the media group was given %d, then %d (%v)", media, mediaAgain, err) + } + plex, err := inv.AllocateIdentity(ctx, "plex") + if err != nil { + t.Fatal(err) + } + seen := map[int]string{} + for who, n := range map[string]int{"radarr": radarr.UID, "plex": plex.UID, "mesh-media": media} { + if n == 0 || !catalogue.AllocatableID(n) { + t.Errorf("%s was given %d: within %d–%d, never 0", who, n, catalogue.IdentityLow, catalogue.IdentityHigh) + } + if other, twice := seen[n]; twice { + t.Errorf("%s and %s share %d: a module's own group and a library's never do", who, other, n) + } + seen[n] = who + } + // Read back as every machine's composition reads them: the same numbers. + ids, err := inv.Identities(ctx) + if err != nil { + t.Fatal(err) + } + groups, err := inv.LibraryGroups(ctx) + if err != nil { + t.Fatal(err) + } + if ids["radarr"].UID != radarr.UID || groups["media"] != media { + t.Fatalf("read back %+v and %v", ids, groups) + } + // A forgotten row frees no number: the next one is new. + 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 _, given := seen[sonarr.UID]; given { + t.Fatalf("sonarr was given %d, a number given before", sonarr.UID) + } + // The store refuses a number outside the range, root's above all. + for _, n := range []int{0, 4, 10, 1000, 49999, 60000} { + if _, err := inv.store.Pool().Exec(ctx, `insert into module_identity (module, uid) values ('byhand', $1)`, n); err == nil { + t.Errorf("the store took %d as a module's number", n) + } + if _, err := inv.store.Pool().Exec(ctx, `insert into library_group (library, gid) values ('byhand', $1)`, n); err == nil { + t.Errorf("the store took %d as a library group's number", n) + } + } +} + +// libraryController registers the controller's own module, whose mesh-wide layer defines the libraries. +func libraryController(t *testing.T, inv *Inventory) { + t.Helper() + m := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1", + Resources: []map[string]any{{"id": "server", "type": "container", "name": "controller", "image": "x"}}} + if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil { + t.Fatal(err) + } +} + +// novox/hq ADR 0328 §1, §4, §13: a library is defined only on the controller's mesh-wide layer, as a trusted setting; +// never on one machine; never overlapping another on a machine it reaches; never named as a registered module — and no +// module is registered under a defined library's name. +func TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + for _, n := range []string{"ace", "novox"} { + if _, err := inv.AddNode(ctx, n); err != nil { + t.Fatal(err) + } + } + libraryController(t, inv) + radarr := catalogue.Manifest{Module: "radarr", Version: "1", + Resources: []map[string]any{{"id": "server", "type": "container", "name": "radarr", "image": "x"}}} + if err := inv.RegisterModule(ctx, radarr, Source{}); err != nil { + t.Fatal(err) + } + media := map[string]any{"home": "ace", "root": "/storage/media", "reach": []any{"novox"}} + define := func(libs map[string]any) map[string]any { + return map[string]any{catalogue.LibrariesSetting: libs} + } + if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, define(map[string]any{"media": media})); err != nil { + t.Fatalf("a library was not defined: %v", err) + } + libs, err := inv.Libraries(ctx) + if err != nil { + t.Fatal(err) + } + if l := libs["media"]; l.Home != "ace" || l.Root != "/storage/media" || !l.Reaches("novox") || !l.Reaches("ace") { + t.Fatalf("the library reads %+v", libs["media"]) + } + for name, c := range map[string]struct { + node string + libs map[string]any + want string + }{ + "on one machine": {"ace", map[string]any{"media": media}, "never on one machine"}, + "inside another": {"", map[string]any{"media": media, + "movies": map[string]any{"home": "novox", "root": "/storage/media/movies"}}, "overlap on"}, + "around another": {"", map[string]any{"movies": map[string]any{"home": "ace", "root": "/storage/media/movies"}, + "media": media}, "overlap on"}, + "named as a module": {"", map[string]any{"radarr": map[string]any{"home": "ace", "root": "/srv/radarr-data"}}, + "name of the module radarr"}, + "with an owner": {"", map[string]any{"books": map[string]any{"home": "ace", "root": "/srv/books", + "owner": "1000"}}, "nothing about an owner"}, + } { + err := inv.SetSettings(ctx, c.node, catalogue.LibrariesModule, define(c.libs)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v; want refused saying %q", name, err, c.want) + } + } + // Two libraries apart on different machines may share a path's prefix only where they do not meet. + apart := map[string]any{"media": media, "photos": map[string]any{"home": "elsewhere", "root": "/storage/media/photos"}} + if err := inv.JudgeSettings(ctx, "", catalogue.LibrariesModule, define(apart)); err != nil { + t.Errorf("libraries that meet on no machine were refused: %v", err) + } + // A module under a defined library's name is refused. + named := catalogue.Manifest{Module: "media", Version: "1", + Resources: []map[string]any{{"id": "s", "type": "container", "name": "media", "image": "x"}}} + if err := inv.RegisterModule(ctx, named, Source{}); err == nil || !strings.Contains(err.Error(), "name of the library media") { + t.Fatalf("a module named as a library: %v", err) + } +} + +// novox/hq ADR 0328 §7: a library taken is recorded once per home and filesystem, under a warrant; one taken again on +// another filesystem replaces it. +func TestALibraryIsTakenOnceUnderAWarrant(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if err := inv.RecordTaken(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil { + t.Fatal("a library was recorded taken with no warrant") + } + first := TakenLibrary{Library: "media", Home: "ace", Placed: "fs-000000000001", By: "the operator", Ask: "c01", + Warrant: "c01"} + if err := inv.RecordTaken(ctx, first); err != nil { + t.Fatal(err) + } + again := first + again.Ask, again.Warrant, again.By = "c02", "c02", "someone else" + if err := inv.RecordTaken(ctx, again); err != nil { + t.Fatal(err) + } + taken, err := inv.TakenLibraries(ctx) + if err != nil { + t.Fatal(err) + } + if got := taken["media"]; got.Ask != "c01" { + t.Fatalf("a second take of the same overwrote the first: %+v", got) + } + moved := first + moved.Placed, moved.Ask, moved.Warrant = "fs-0000000000ff", "c03", "c03" + if err := inv.RecordTaken(ctx, moved); err != nil { + t.Fatal(err) + } + if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c03" { + t.Fatalf("a take on another filesystem: %+v (%v)", taken["media"], err) + } +} + +// 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 deleted file mode 100644 index 34eb6024..00000000 --- a/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql +++ /dev/null @@ -1,44 +0,0 @@ --- 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/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql b/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql new file mode 100644 index 00000000..6b8008e9 --- /dev/null +++ b/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql @@ -0,0 +1,44 @@ +-- A module that joins has a user and a group of its own, each library has a group of its own, and a library's +-- existing data is taken once, on the operator's word (novox/hq ADR 0328 §3, §4, §7, §15). +-- +-- **One sequence for every number the mesh gives.** A joining module is given one number, its user's and its own +-- group's (mesh-); a library is given one, its group's (mesh-). Both are drawn from this sequence, so +-- a module's own group and a library's group never share a number. The range is 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 it, so no write, by hand or by a +-- later bug, can make one root's (§15). A sequence never hands a number back, and a row is kept after its module or +-- library is gone, so a number once given is never given to another: a file left owned by a removed module's user is +-- never inherited by a new one. +create sequence mesh_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), + allocated timestamptz not null default now() +); + +create table library_group ( + library text primary key, + gid integer not null unique check (gid between 50000 and 59999), + allocated timestamptz not null default now() +); + +-- **A library taken** (§7). The first time a library's root on its home holds data not yet in its group, the operator +-- is asked once for that library; on the warrant the node-engine sets the group, group write and setgid under the root, +-- and the library is taken: kept from then on without asking. The row is the record of that act — on which machine +-- and filesystem, who approved it, under which warrant. A library whose home or filesystem changed since (it moved to +-- another machine or another disk) is not taken by that warrant, and the operator is asked again; a new take replaces +-- the row. +create table kept_library ( + library text primary key, + home text not null, + -- The filesystem the root was on when taken, as the engine names it without a path ("fs-<12 hex>"); empty when + -- the engine named none. + placed text not null default '', + taken_at timestamptz not null default now(), + 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 '' +); diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 3498e26d..6c9e9c5a 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -445,6 +445,16 @@ const RootContract = 3 // superseded), is what engines stated before; the controller sends no grant to any engine (#270). const IdentityContract = 5 +// LibraryContract is the statement of an engine that makes the mesh's users and library groups and keeps libraries +// (novox/hq ADR 0328, which supersedes ADR 0325): it reads a declaration's `groups`, `identities` and `libraries`, +// states a library it may not keep yet as waiting, and hears the take of a library. Only to such an engine is any of +// it sent; five, ADR 0325's, is sent nothing new. +const LibraryContract = 6 + +// KindLibrary is a library's own verdict on its home, as the engine may state it (novox/hq ADR 0328): healthy once +// kept. Read beside an access verdict naming the library until the engine's shape is settled. +const KindLibrary = "library" + // 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" @@ -605,19 +615,15 @@ type AccessWait struct { // 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 is what the wait is for: WaitKeep (the operator's word to take the library) or WaitDisk (its disk + // mounted). 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). +// The reasons of an access wait (mesh-host internal/link). const ( - WaitKeep = "keep" - WaitDisk = "disk" - ACLNeedsEnabling = "needs-enabling" - ACLUnsupported = "unsupported" + WaitKeep = "keep" + WaitDisk = "disk" ) // KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user @@ -630,16 +636,6 @@ const KindAccess = "access" // 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"` @@ -667,10 +663,9 @@ type ResourceHealth struct { // 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. + // folder, kept as the engine said it. From an engine at IdentityContract or later. 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/testdata/store-tests b/testdata/store-tests index b762e28b..8bbaf02c 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -35,9 +35,10 @@ ./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 +# novox/hq ADR 0328 §1, §3, §4, §7, §13, §15 and issue 575: numbers allocated once and never root, libraries defined +# once and never overlapping, a library taken under a warrant, a placement judged against the build that brings it. +./internal/inventory TestOneNumberPerModuleAndLibraryNeverRoot +./internal/inventory TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps +./internal/inventory TestALibraryIsTakenOnceUnderAWarrant ./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess ./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead