Libraries and the modules that join them, on the controller's side (hq ADR 0328)
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user