Libraries and the modules that join them, on the controller's side (hq ADR 0328)

This commit is contained in:
2026-10-12 22:29:35 +02:00
parent fa0a5f1c88
commit f634e33792
32 changed files with 2915 additions and 2546 deletions
+9 -9
View File
@@ -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") {
+3 -5
View File
@@ -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)
}
}
+4 -4
View File
@@ -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.
-394
View File
@@ -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 <path> on <machine>, now for <modules>: …". 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 <path> on <machine>, now
// for <modules>: …".
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
}
-563
View File
@@ -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)
}
}
+382
View File
@@ -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
// <name> at <root> on <machine>: …". 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:<name>" 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
}
+420
View File
@@ -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:<name>.
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:<name> 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")
}
}
+1 -13
View File
@@ -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
}
-9
View File
@@ -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")
+64 -68
View File
@@ -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 "<module>@<machine>", 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:<name>".
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
}
}
}
+124 -52
View File
@@ -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
+12 -3
View File
@@ -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".