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".
|
||||
|
||||
@@ -247,19 +247,23 @@ type Rendering struct {
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// KeepsFolders says this machine's node-engine gives modules their identities and keeps shared folders
|
||||
// (novox/hq ADR 0325: its statement's contract is link.IdentityContract or later). To an older, strict engine none of
|
||||
// it is sent — no `identities`, no container `identity`, no access `users` or `taken`, no owner derived from an
|
||||
// identity — and what it runs is composed as before.
|
||||
KeepsFolders bool
|
||||
// Identities is each module's allocated identity, by module (ADR 0325 §3): the same on every machine. A module
|
||||
// that declares `identity` and has none here is composed without one, as to an older engine.
|
||||
// KeepsLibraries says this machine's node-engine makes the mesh's users and library groups and keeps libraries
|
||||
// (novox/hq ADR 0328: its statement's contract is link.LibraryContract or later). To an older, strict engine none
|
||||
// of it is sent, and a module that joins is left out of its declaration, naming why.
|
||||
KeepsLibraries bool
|
||||
// Identities is each joining module's allocated number, by module (ADR 0328 §4): the same on every machine. A
|
||||
// joining module with none here is left out, as at a question asked before its first send.
|
||||
Identities map[string]ModuleIdentity
|
||||
// KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path.
|
||||
KeptFolders map[string]bool
|
||||
// ReadyFolders is every folder the engine states it keeps on this machine (its access healthy), by clean path: a
|
||||
// folder it found empty and kept, or took itself. With KeptFolders, what lets a container switch to its identity.
|
||||
ReadyFolders map[string]bool
|
||||
// Libraries is every library the operator defined, by name (§1); LibraryGroups each one's allocated group number
|
||||
// (§3); TakenLibraries those whose data the operator let the mesh take on their home, on the filesystem it is on
|
||||
// now (§7); LibraryPaths every sub-path any module in the mesh asks of each, kept on its home (§6).
|
||||
Libraries map[string]Library
|
||||
LibraryGroups map[string]int
|
||||
TakenLibraries map[string]bool
|
||||
LibraryPaths map[string][]string
|
||||
// Held is, by module, the library a joining module that ran here before waits on: until every library it uses
|
||||
// is taken its containers keep the user they ran as, so it is left out of the declaration (§8).
|
||||
Held map[string]string
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -334,9 +338,11 @@ type Composed struct {
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// Identities is the declaration's `identities` (novox/hq ADR 0325 §3): [{module, name, uid, gid}] for each
|
||||
// module composed with an identity. Empty to an engine that does not keep folders.
|
||||
Identities []map[string]any
|
||||
// Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR
|
||||
// 0328): composed only for an engine that keeps libraries, and sent only when not empty.
|
||||
Groups []GroupEntry
|
||||
Identities []IdentityEntry
|
||||
Libraries []LibraryEntry
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -399,8 +405,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
var identities []ModuleIdentity
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities)
|
||||
var joined []Joined
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -416,9 +422,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
sort.Strings(unplaced)
|
||||
composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}
|
||||
if len(identities) > 0 {
|
||||
composed.Identities = identitiesOf(identities)
|
||||
}
|
||||
composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined)
|
||||
return composed, nil
|
||||
}
|
||||
|
||||
@@ -430,13 +434,26 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
received map[string]map[string][]Contribution, leftOut map[string]string,
|
||||
foreseen, unplaced *[]string, identities *[]ModuleIdentity) ([]map[string]any, error) {
|
||||
foreseen, unplaced *[]string, joinedOut *[]Joined) ([]map[string]any, error) {
|
||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||
// containers untouched, the machine told so by name — and the machine is told everything else.
|
||||
// Before placing, because a placement is a setting too.
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
// And a module whose libraries cannot be composed here, or that waits for one to be taken (novox/hq ADR 0328 §2,
|
||||
// §8): left out, naming why, its containers untouched — so a container keeps the user it ran as.
|
||||
for _, m := range r.Modules {
|
||||
if _, already := left[m.Module]; already {
|
||||
continue
|
||||
}
|
||||
if why := libraryLeftOut(m, r.Node, with); why != "" {
|
||||
if left == nil {
|
||||
left = map[string]string{}
|
||||
}
|
||||
left[m.Module] = why
|
||||
}
|
||||
}
|
||||
kept := make([]Manifest, 0, len(r.Modules))
|
||||
var stillBackedUp []Manifest
|
||||
for _, m := range r.Modules {
|
||||
@@ -577,18 +594,19 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
resources := m.Resources
|
||||
|
||||
// The identity it runs as here (novox/hq ADR 0325 §3, §11): none to an older engine, nor without an allocation.
|
||||
ident, hasIdent, err := identityFor(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Its identity, where it joins (novox/hq ADR 0328 §4): its user and groups, and where its libraries are here.
|
||||
var joined Joined
|
||||
joins := Joins(m)
|
||||
var libPaths map[string]string
|
||||
if joins {
|
||||
if joined, err = joinedOf(m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libPaths = libraryPaths(m, with)
|
||||
if joinedOut != nil {
|
||||
*joinedOut = append(*joinedOut, joined)
|
||||
}
|
||||
}
|
||||
if hasIdent && identities != nil {
|
||||
*identities = append(*identities, ident)
|
||||
}
|
||||
mountedByIdentity := dirsMountedByIdentity(m)
|
||||
// Its containers run as it only once every read-write folder they mount is taken (the operator's question on
|
||||
// ADR 0325): until then the identity is allocated, sent and named in the take's users, and not switched to.
|
||||
runsAsIdent := hasIdent && !identityHeld(m, with)
|
||||
|
||||
// What the mesh computes for this module goes FIRST, before the module's own resources.
|
||||
//
|
||||
@@ -1040,10 +1058,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
ownerInto(copied, placed)
|
||||
// And its identity (novox/hq ADR 0325 §2, §3): a container's resolved, or taken out where none is composed;
|
||||
// the owner of a directory its identity's containers mount. After the placement's owner: the identity wins.
|
||||
if err := identityInto(copied, m, ident, runsAsIdent, mountedByIdentity); err != nil {
|
||||
return nil, err
|
||||
// And its identity and libraries, where it joins (novox/hq ADR 0328 §4, §5, §10): after the placement's
|
||||
// owner, which a joining module may not set.
|
||||
if joins {
|
||||
if err := joinedInto(copied, m, joined, libPaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// **After settings, and that is the whole reason it is here.** A module's file
|
||||
// content is where a setting lands, so a placeholder may only exist once the setting
|
||||
@@ -1162,12 +1182,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **Who uses each of its shared folders** (novox/hq ADR 0325 §4, §5, §8): the access's `users` and `taken`, to
|
||||
// an engine that keeps folders.
|
||||
if with.KeepsFolders {
|
||||
usersInto(out, owner, m, ident, hasIdent, r.toolsAccount(m, with), with)
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Libraries, and the modules that join them (novox/hq ADR 0328).
|
||||
//
|
||||
// **A library is a named tree of shared data with one home** (§1). The operator defines it — its name, the machine it
|
||||
// lives on, its root there and the machines it reaches — as the mesh-wide `libraries` setting of the controller's own
|
||||
// module, a trusted setting set at the controller's terminal or on the operator's Approve of a proposal (ADR 0277),
|
||||
// never through a verb alone. Libraries never overlap on a machine (§13), and a library never shares a name with a
|
||||
// module (§4), so `mesh-<library>` and `mesh-<module>` never collide.
|
||||
//
|
||||
// **A module asks for a library by name, sub-path and use** (§2): a manifest's `libraries` entries {id, library,
|
||||
// path, use}. On each machine an entry resolves to the library's root joined with the path; a module assigned to a
|
||||
// machine the library does not reach is left out there, naming the library. `${library:<id>}` is that host path, and
|
||||
// is filled only where a host path belongs (§5): a container's volume source, and a word of the module's tools, which
|
||||
// run on the machine. A `read` entry is mounted read-only (§10).
|
||||
//
|
||||
// **A module joins by using the model** (§14): it declares `libraries` or uses `${identity:…}`. It is given one number
|
||||
// from the mesh's range, as its user's and its own group's (§4); its user's primary group is the group of the first
|
||||
// library it uses, the others are added groups, and with no library its own group is primary. `${identity:user}`,
|
||||
// `${identity:uid}`, `${identity:group}` and `${identity:gid}` are filled wherever its resources take values (§5), and
|
||||
// its directories are owned by its user and primary group (§4). A module that does neither is composed exactly as
|
||||
// before.
|
||||
//
|
||||
// **The range is 50000–59999** for every number the mesh allocates — a joining module's and a library's group — from
|
||||
// one sequence, so a module's own group and a library's never share a number. It lies above the numbers distributions
|
||||
// give people and below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a root group's (0, adm's 4,
|
||||
// wheel's 10): no allocated number is root's (§15). The store refuses a row outside it.
|
||||
//
|
||||
// **Only to an engine that reads it** (Rendering.KeepsLibraries: its statement's contract is link.LibraryContract or
|
||||
// later), the declaration carries `groups`, `identities` and `libraries` (libraryEnvelope). To an older engine a module
|
||||
// that joins is left out of the declaration, naming why — its placeholders could not be filled — and everything else is
|
||||
// composed as before.
|
||||
|
||||
// The range the mesh allocates user and group numbers from (novox/hq ADR 0328 §3, §4, §15). The store holds the same
|
||||
// bounds.
|
||||
const (
|
||||
IdentityLow = 50000
|
||||
IdentityHigh = 59999
|
||||
)
|
||||
|
||||
// AllocatableID says whether n is a number the mesh may give: inside the range, so never 0 nor a root group's.
|
||||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||||
|
||||
// ADR0328 is how the checks name the decision their rules come from.
|
||||
const ADR0328 = "novox/hq ADR 0328"
|
||||
|
||||
// MeshName is the user or group a module or a library is on every machine: mesh-<name>.
|
||||
func MeshName(name string) string { return "mesh-" + name }
|
||||
|
||||
// IdentityName is the user a joining module runs as, and its own group.
|
||||
func IdentityName(module string) string { return MeshName(module) }
|
||||
|
||||
// ---- libraries, as the operator defines them -----------------------------------------------------------------
|
||||
|
||||
// LibrariesSetting is the key of the controller's mesh-wide settings layer that defines the libraries (§1).
|
||||
const LibrariesSetting = "libraries"
|
||||
|
||||
// LibrariesModule is the module whose mesh-wide layer holds LibrariesSetting: the controller's own.
|
||||
const LibrariesModule = "mesh-controller"
|
||||
|
||||
// Library is one library as the operator defined it.
|
||||
type Library struct {
|
||||
Name string `json:"-"`
|
||||
// Home is the machine the data lives on; Root its path there, the same on every machine it reaches.
|
||||
Home string `json:"home"`
|
||||
Root string `json:"root"`
|
||||
// Reach is every machine the library reaches, its home among them.
|
||||
Reach []string `json:"reach,omitempty"`
|
||||
}
|
||||
|
||||
// Reaches says whether the library reaches a machine: its home, or one it names.
|
||||
func (l Library) Reaches(node string) bool { return node == l.Home || hasName(l.Reach, node) }
|
||||
|
||||
// Machines is every machine the library reaches, its home first.
|
||||
func (l Library) Machines() []string {
|
||||
out := []string{l.Home}
|
||||
for _, n := range l.Reach {
|
||||
if n != l.Home && !hasName(out, n) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Group is the library's group: mesh-<library>.
|
||||
func (l Library) Group() string { return MeshName(l.Name) }
|
||||
|
||||
var libraryName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
// ParseLibraries reads the `libraries` setting: {<name>: {home, root, reach}}, or refuses it in words.
|
||||
func ParseLibraries(v any) (map[string]Library, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
blocks, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a { <library>: {home, root, reach} } map (%s §1)", LibrariesSetting, ADR0328)
|
||||
}
|
||||
out := map[string]Library{}
|
||||
for name, raw := range blocks {
|
||||
if !libraryName.MatchString(name) {
|
||||
return nil, fmt.Errorf("the library %q: a library's name is lowercase letters, digits and dashes, starting "+
|
||||
"with a letter", name)
|
||||
}
|
||||
fields, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s is {home, root, reach}, not %v", name, raw)
|
||||
}
|
||||
l := Library{Name: name}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "home":
|
||||
l.Home, _ = v.(string)
|
||||
case "root":
|
||||
l.Root, _ = v.(string)
|
||||
case "reach":
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s's reach is a list of machines", name)
|
||||
}
|
||||
for _, n := range list {
|
||||
s, _ := n.(string)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("the library %s's reach names a machine that is not a name: %v", name, n)
|
||||
}
|
||||
l.Reach = append(l.Reach, s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("the library %s says %q; a library is its home, its root and its reach, and "+
|
||||
"nothing about an owner, a group, a mode or a number (%s §1)", name, k, ADR0328)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(l.Home) == "" {
|
||||
return nil, fmt.Errorf("the library %s names no home machine", name)
|
||||
}
|
||||
if !strings.HasPrefix(l.Root, "/") {
|
||||
return nil, fmt.Errorf("the library %s's root %q is not an absolute path", name, l.Root)
|
||||
}
|
||||
l.Root = filepath.Clean(l.Root)
|
||||
if why := systemPath(l.Root); why != "" {
|
||||
return nil, fmt.Errorf("the library %s's root is %s: %s, and the node-engine keeps a library's root in its "+
|
||||
"group — no library is ever there", name, l.Root, why)
|
||||
}
|
||||
sort.Strings(l.Reach)
|
||||
out[name] = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LibrariesProblems refuses what no library may be together (§4, §13): a library named as a registered module, and two
|
||||
// libraries on one machine whose roots lie inside each other — each named.
|
||||
func LibrariesProblems(libs map[string]Library, modules []string) []string {
|
||||
var problems []string
|
||||
names := make([]string, 0, len(libs))
|
||||
for n := range libs {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if hasName(modules, n) {
|
||||
problems = append(problems, fmt.Sprintf("the library %s has the name of the module %s: %s would be both "+
|
||||
"(%s §4)", n, n, MeshName(n), ADR0328))
|
||||
}
|
||||
}
|
||||
for i, a := range names {
|
||||
for _, b := range names[i+1:] {
|
||||
la, lb := libs[a], libs[b]
|
||||
shared := ""
|
||||
for _, m := range la.Machines() {
|
||||
if lb.Reaches(m) {
|
||||
shared = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if shared == "" {
|
||||
continue
|
||||
}
|
||||
if within(la.Root, lb.Root) || within(lb.Root, la.Root) {
|
||||
problems = append(problems, fmt.Sprintf("the libraries %s (%s) and %s (%s) overlap on %s: a library's "+
|
||||
"root lies neither inside another's nor around it — divide one library by the paths its modules "+
|
||||
"ask for (%s §13)", a, la.Root, b, lb.Root, shared, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// within says path is root or lies below it.
|
||||
func within(path, root string) bool {
|
||||
return path == root || strings.HasPrefix(path, strings.TrimSuffix(root, "/")+"/")
|
||||
}
|
||||
|
||||
func hasName(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- a module's use of them ---------------------------------------------------------------------------------
|
||||
|
||||
// LibraryUse is one `libraries` entry of a manifest: the id the module's resources name it by, the library, the
|
||||
// sub-path within it, and the use.
|
||||
type LibraryUse struct {
|
||||
ID string `json:"id"`
|
||||
Library string `json:"library"`
|
||||
Path string `json:"path"`
|
||||
Use string `json:"use"`
|
||||
}
|
||||
|
||||
// At is the entry's use, read by default.
|
||||
func (u LibraryUse) At() string {
|
||||
if u.Use == "" {
|
||||
return AccessRead
|
||||
}
|
||||
return u.Use
|
||||
}
|
||||
|
||||
// libraryRef is how a module names one of its library entries: ${library:<id>}.
|
||||
var libraryRef = regexp.MustCompile(`\$\{library:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
// identityRef is a module's identity by name: ${identity:user|uid|group|gid}.
|
||||
var identityRef = regexp.MustCompile(`\$\{identity:(user|uid|group|gid)\}`)
|
||||
|
||||
// Joins says a module joins the model (§14): it declares libraries or uses its identity anywhere.
|
||||
func Joins(m Manifest) bool {
|
||||
if len(m.Libraries) > 0 {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
walkStrings(r, func(s string) {
|
||||
found = found || identityRef.MatchString(s) || strings.Contains(s, "${identity:")
|
||||
})
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
for _, v := range b.Env {
|
||||
found = found || strings.Contains(v, "${identity:")
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// walkStrings calls see with every string a value holds, however deep.
|
||||
func walkStrings(v any, see func(string)) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
see(v)
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStrings is v with every string it holds replaced by fill's answer, however deep; the first error stops it.
|
||||
func mapStrings(v any, fill func(string) (string, error)) (any, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return fill(v)
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = y
|
||||
}
|
||||
return out, nil
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for k, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = y
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var libraryEntryID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// numberWords are the setting names that carry a user or group number or an owner: none for a module that joins (§5).
|
||||
// "user" and "group" are left out: an application's own login or group name is a setting like any other.
|
||||
var numberWords = map[string]bool{"uid": true, "gid": true, "puid": true, "pgid": true, "user-id": true,
|
||||
"group-id": true, "owner": true}
|
||||
|
||||
// libraryProblems is what the module check refuses of a manifest's libraries and identity (§2, §4, §5, §14):
|
||||
//
|
||||
// - an entry that is not {id, library, path, use} with a relative sub-path inside its library, or names a host
|
||||
// path, an owner, a group, a mode or a number (the entry's shape refuses any other key);
|
||||
// - two entries with one id, or a `${library:<id>}` naming none;
|
||||
// - `${library:<id>}` anywhere but a container's volume source (and a word of its tools, a bundle's env);
|
||||
// - `${identity:…}` other than user, uid, group or gid;
|
||||
// - in a module that joins: a directory naming its owner. A setting of it naming a number or an owner is refused
|
||||
// where it is set (JoiningSettingProblem).
|
||||
func libraryProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
ids := map[string]bool{}
|
||||
for _, u := range m.Libraries {
|
||||
where := fmt.Sprintf("%s's library entry %q", m.Module, u.ID)
|
||||
switch {
|
||||
case !libraryEntryID.MatchString(u.ID):
|
||||
problems = append(problems, where+": an id is lowercase letters, digits and dashes")
|
||||
case ids[u.ID]:
|
||||
problems = append(problems, where+": two entries share the id")
|
||||
}
|
||||
ids[u.ID] = true
|
||||
if !libraryName.MatchString(u.Library) {
|
||||
problems = append(problems, fmt.Sprintf("%s names the library %q, which is not a library's name", where,
|
||||
u.Library))
|
||||
}
|
||||
clean := filepath.Clean(u.Path)
|
||||
if u.Path == "" || strings.HasPrefix(u.Path, "/") || clean == ".." || strings.HasPrefix(clean, "../") ||
|
||||
strings.ContainsAny(u.Path, "${}") {
|
||||
problems = append(problems, fmt.Sprintf("%s's path %q is a sub-path inside the library, relative and "+
|
||||
"staying inside it — never a host path (%s §2)", where, u.Path, ADR0328))
|
||||
}
|
||||
if u.At() != AccessRead && u.At() != AccessReadWrite {
|
||||
problems = append(problems, fmt.Sprintf("%s is used %q; a use is %q or %q", where, u.Use, AccessRead,
|
||||
AccessReadWrite))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
kind := fmt.Sprint(r["type"])
|
||||
for field, v := range r {
|
||||
walkStrings(v, func(s string) {
|
||||
for _, tok := range regexp.MustCompile(`\$\{identity:[^}]*\}`).FindAllString(s, -1) {
|
||||
if !identityRef.MatchString(tok) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s: the identity is "+
|
||||
"${identity:user}, ${identity:uid}, ${identity:group} or ${identity:gid} (%s §5)", m.Module,
|
||||
r["id"], tok, ADR0328))
|
||||
}
|
||||
}
|
||||
for _, match := range libraryRef.FindAllStringSubmatch(s, -1) {
|
||||
if !ids[match[1]] {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says ${library:%s}, and declares no "+
|
||||
"library entry %q", m.Module, r["id"], match[1], match[1]))
|
||||
}
|
||||
source := ""
|
||||
if kind == "container" && field == "volumes" {
|
||||
source = volumeSource(s)
|
||||
}
|
||||
if !strings.Contains(source, match[0]) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s in its %s: a library's path "+
|
||||
"is a host path, filled only in a container's volume source — the process reads the "+
|
||||
"container side its volume gives (%s §5)", m.Module, r["id"], match[0], field, ADR0328))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if !Joins(m) {
|
||||
return problems
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, said := r["owner"]; said {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v names its owner %v; a module that joins "+
|
||||
"owns its directories by its user and primary group, and names no owner (%s §4)", m.Module,
|
||||
r["id"], owner, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// LibraryWarnings is what the module check warns of a module's libraries (§4): an image that drops added groups
|
||||
// reaches only the first library the module uses.
|
||||
func LibraryWarnings(m Manifest) []string {
|
||||
seen := map[string]bool{}
|
||||
var libs []string
|
||||
for _, u := range m.Libraries {
|
||||
if !seen[u.Library] {
|
||||
seen[u.Library] = true
|
||||
libs = append(libs, u.Library)
|
||||
}
|
||||
}
|
||||
if len(libs) < 2 {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s uses the libraries %s: its user's primary group is %s, and %s are added groups — an "+
|
||||
"image that drops added groups when it changes user reaches only %s (%s §4)", m.Module, strings.Join(libs, ", "),
|
||||
MeshName(libs[0]), strings.Join(meshNames(libs[1:]), ", "), libs[0], ADR0328)}
|
||||
}
|
||||
|
||||
func meshNames(names []string) []string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = MeshName(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JoiningSettingProblem refuses, where a setting is set, a layer of a module that joins that carries a user or group
|
||||
// number or an owner (§5): a key whose name says so, or an owner a `places` setting names — its directories are owned
|
||||
// by its user and primary group, and it reads its own as ${identity:…}.
|
||||
func JoiningSettingProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
for _, l := range layers {
|
||||
for k := range l.Values {
|
||||
if numberWords[k] {
|
||||
return fmt.Errorf("%s joins, and the layer %q sets %q: no setting of a module that joins carries a user "+
|
||||
"or group number or an owner — it reads its own as ${identity:…} (%s §5)", m.Module, l.From, k, ADR0328)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PlacesOwnerProblem(m, layers)
|
||||
}
|
||||
|
||||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that joins (§4, §5): its directories are
|
||||
// owned by its user and primary group.
|
||||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
placed, err := Places(m, layers)
|
||||
if err != nil {
|
||||
return nil // refused by name where it is judged
|
||||
}
|
||||
var owned []string
|
||||
for id, p := range placed {
|
||||
if p.Owner != "" {
|
||||
owned = append(owned, id)
|
||||
}
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(owned)
|
||||
return fmt.Errorf("%s places %s with an owner, and it joins: its directories are owned by its user and primary "+
|
||||
"group, and no setting of it carries an owner (%s §4, §5)", m.Module, strings.Join(owned, ", "), ADR0328)
|
||||
}
|
||||
|
||||
// ---- composition ------------------------------------------------------------------------------------------------
|
||||
|
||||
// ModuleIdentity is a joining module's allocated number: its user's and its own group's.
|
||||
type ModuleIdentity struct {
|
||||
Module string
|
||||
UID int
|
||||
}
|
||||
|
||||
// Joined is a joining module's identity on a machine, as its placeholders are filled and its directories owned.
|
||||
type Joined struct {
|
||||
Module string
|
||||
UID int
|
||||
// Primary is its user's primary group, by name and number; Added its other groups, by name, in the order used.
|
||||
Primary string
|
||||
PrimaryGID int
|
||||
Added []string
|
||||
}
|
||||
|
||||
// librariesOf is the libraries a module uses, by name, in the order its entries first name them.
|
||||
func librariesOf(m Manifest) []string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
if !hasName(out, u.Library) {
|
||||
out = append(out, u.Library)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// joinedOf is module m's identity here: its number, and its groups from the libraries it uses. Refused, naming
|
||||
// what, where a number is missing or outside the range (§15).
|
||||
func joinedOf(m Manifest, with Rendering) (Joined, error) {
|
||||
id, has := with.Identities[m.Module]
|
||||
if !has {
|
||||
return Joined{}, fmt.Errorf("%s joins, and has no number yet: it is allocated at its first send", m.Module)
|
||||
}
|
||||
if !AllocatableID(id.UID) {
|
||||
return Joined{}, fmt.Errorf("%s's number is %d, outside %d–%d: no number the mesh gives is root's or a root "+
|
||||
"group's, and none is sent (%s §15)", m.Module, id.UID, IdentityLow, IdentityHigh, ADR0328)
|
||||
}
|
||||
j := Joined{Module: m.Module, UID: id.UID, Primary: IdentityName(m.Module), PrimaryGID: id.UID}
|
||||
for i, lib := range librariesOf(m) {
|
||||
gid, has := with.LibraryGroups[lib]
|
||||
if !has || !AllocatableID(gid) {
|
||||
return Joined{}, fmt.Errorf("%s uses the library %s, whose group has no number the mesh may give (%s §3, "+
|
||||
"§15)", m.Module, lib, ADR0328)
|
||||
}
|
||||
if i == 0 {
|
||||
j.Primary, j.PrimaryGID = MeshName(lib), gid
|
||||
continue
|
||||
}
|
||||
j.Added = append(j.Added, MeshName(lib))
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// libraryLeftOut is why module m is left out of this machine's declaration for its libraries, or "": a library it
|
||||
// uses not defined or not reaching the machine (§2), an engine that cannot be told it joins, or a container held at
|
||||
// the user it ran as until every library it uses is taken (§8).
|
||||
func libraryLeftOut(m Manifest, node string, with Rendering) string {
|
||||
if !Joins(m) {
|
||||
return ""
|
||||
}
|
||||
if !with.KeepsLibraries {
|
||||
return fmt.Sprintf("%s joins the mesh's users and libraries (%s), and the node-engine on %s is older than "+
|
||||
"them: it is left out until that engine is updated", m.Module, ADR0328, node)
|
||||
}
|
||||
for _, lib := range librariesOf(m) {
|
||||
l, defined := with.Libraries[lib]
|
||||
switch {
|
||||
case !defined:
|
||||
return fmt.Sprintf("%s uses the library %s, which is not defined: the operator defines it (%s §1)",
|
||||
m.Module, lib, ADR0328)
|
||||
case !l.Reaches(node):
|
||||
return fmt.Sprintf("%s uses the library %s, which does not reach %s (%s §2)", m.Module, lib, node, ADR0328)
|
||||
}
|
||||
}
|
||||
if _, has := with.Identities[m.Module]; !has {
|
||||
return fmt.Sprintf("%s joins, and its number is allocated at its first send", m.Module)
|
||||
}
|
||||
if lib := with.Held[m.Module]; lib != "" {
|
||||
return fmt.Sprintf("%s keeps the user it ran as until the library %s is taken: the operator is asked once "+
|
||||
"(%s §8)", m.Module, lib, ADR0328)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// libraryPaths is where each of module m's library entries is on this machine: its library's root joined with the
|
||||
// entry's path, by entry id.
|
||||
func libraryPaths(m Manifest, with Rendering) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, u := range m.Libraries {
|
||||
if l, ok := with.Libraries[u.Library]; ok {
|
||||
out[u.ID] = filepath.Join(l.Root, filepath.Clean(u.Path))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityFill fills ${identity:…} in one string.
|
||||
func identityFill(s string, j Joined) string {
|
||||
return identityRef.ReplaceAllStringFunc(s, func(tok string) string {
|
||||
switch identityRef.FindStringSubmatch(tok)[1] {
|
||||
case "user":
|
||||
return IdentityName(j.Module)
|
||||
case "uid":
|
||||
return fmt.Sprint(j.UID)
|
||||
case "group":
|
||||
return j.Primary
|
||||
default:
|
||||
return fmt.Sprint(j.PrimaryGID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// joinedInto composes one resource of a joining module (§4, §5, §10): ${identity:…} filled everywhere, a container's
|
||||
// ${library:<id>} volume sources filled with the host path and a read entry mounted read-only, and a directory owned by
|
||||
// the module's user and primary group.
|
||||
func joinedInto(resource map[string]any, m Manifest, j Joined, paths map[string]string) error {
|
||||
for k, v := range resource {
|
||||
if k == "id" || k == "type" {
|
||||
continue
|
||||
}
|
||||
filled, err := mapStrings(v, func(s string) (string, error) { return identityFill(s, j), nil })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resource[k] = filled
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.PrimaryGID)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "container" {
|
||||
return nil
|
||||
}
|
||||
volumes, _ := resource["volumes"].([]any)
|
||||
out := make([]any, len(volumes))
|
||||
for i, v := range volumes {
|
||||
out[i] = v
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := volumeSource(s)
|
||||
match := libraryRef.FindStringSubmatch(source)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
path, has := paths[match[1]]
|
||||
if !has {
|
||||
return fmt.Errorf("%s says ${library:%s}, and has no library entry %q", m.Module, match[1], match[1])
|
||||
}
|
||||
filled := strings.Replace(source, match[0], path, 1) + strings.TrimPrefix(s, source)
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == match[1] && u.At() == AccessRead {
|
||||
filled = readOnly(filled)
|
||||
}
|
||||
}
|
||||
out[i] = filled
|
||||
}
|
||||
if len(volumes) > 0 {
|
||||
resource["volumes"] = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOnly is a volume mounted read-only: "src:dst" becomes "src:dst:ro", and options gain ro in place of rw.
|
||||
func readOnly(v string) string {
|
||||
parts := strings.Split(v, ":")
|
||||
if len(parts) < 2 {
|
||||
return v
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return v + ":ro"
|
||||
}
|
||||
opts := strings.Split(parts[len(parts)-1], ",")
|
||||
kept := []string{"ro"}
|
||||
for _, o := range opts {
|
||||
if o != "ro" && o != "rw" && o != "" {
|
||||
kept = append(kept, o)
|
||||
}
|
||||
}
|
||||
parts[len(parts)-1] = strings.Join(kept, ",")
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||||
// "${dir:config}:/config" is "${dir:config}".
|
||||
func volumeSource(v string) string {
|
||||
depth := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r == '{' && i > 0 && v[i-1] == '$':
|
||||
depth++
|
||||
case r == '}' && depth > 0:
|
||||
depth--
|
||||
case r == ':' && depth == 0:
|
||||
return v[:i]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// bundleLibraryFill fills a joining module's tool words (a bundle's env): ${identity:…}, and ${library:<id>} with the
|
||||
// host path, since its tools run on the machine (§5).
|
||||
func bundleLibraryFill(value string, m Manifest, with Rendering) (string, error) {
|
||||
if !strings.Contains(value, "${identity:") && !strings.Contains(value, "${library:") {
|
||||
return value, nil
|
||||
}
|
||||
j, err := joinedOf(m, with)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paths := libraryPaths(m, with)
|
||||
var missing error
|
||||
value = libraryRef.ReplaceAllStringFunc(identityFill(value, j), func(tok string) string {
|
||||
id := libraryRef.FindStringSubmatch(tok)[1]
|
||||
p, has := paths[id]
|
||||
if !has {
|
||||
missing = fmt.Errorf("%s's tools say ${library:%s}, and it has no library entry %q", m.Module, id, id)
|
||||
return tok
|
||||
}
|
||||
return p
|
||||
})
|
||||
return value, missing
|
||||
}
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// LibraryEntry is one library as a machine's declaration names it (§3, §6, §7, §11).
|
||||
type LibraryEntry struct {
|
||||
Library string `json:"library"`
|
||||
Root string `json:"root"`
|
||||
Group string `json:"group"`
|
||||
GID int `json:"gid"`
|
||||
// Home says this machine is the library's home, where the engine keeps it; Taken that the operator let the mesh
|
||||
// take its existing data there (§7).
|
||||
Home bool `json:"home"`
|
||||
Taken bool `json:"taken"`
|
||||
// Paths are the sub-paths its modules ask for, relative to its root.
|
||||
Paths []string `json:"paths"`
|
||||
// Runner is the tool runner's account where a module whose tools run there uses the library: a member of its
|
||||
// group (§11). Empty otherwise.
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// GroupEntry is one group a machine's declaration names: a library's, made on every machine the library reaches (§3).
|
||||
type GroupEntry struct {
|
||||
Name string `json:"name"`
|
||||
GID int `json:"gid"`
|
||||
}
|
||||
|
||||
// IdentityEntry is one joining module's user as a machine's declaration names it (§4): its own group has its uid,
|
||||
// and Groups are its library groups, the primary first.
|
||||
type IdentityEntry struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library
|
||||
// that reaches it, each joining module composed here, and each library a module here uses or that has its home here.
|
||||
func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEntry, []IdentityEntry, []LibraryEntry) {
|
||||
if !with.KeepsLibraries {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var groups []GroupEntry
|
||||
for name, l := range with.Libraries {
|
||||
if gid, has := with.LibraryGroups[name]; has && l.Reaches(r.Node) && AllocatableID(gid) {
|
||||
groups = append(groups, GroupEntry{Name: l.Group(), GID: gid})
|
||||
}
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
||||
var ids []IdentityEntry
|
||||
for _, j := range joined {
|
||||
e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.UID}
|
||||
if j.Primary != IdentityName(j.Module) {
|
||||
e.Groups = append([]string{j.Primary}, j.Added...)
|
||||
}
|
||||
ids = append(ids, e)
|
||||
}
|
||||
sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module })
|
||||
used := map[string]map[string]bool{}
|
||||
runner := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
if with.Held[m.Module] == "" && libraryLeftOut(m, r.Node, with) != "" {
|
||||
continue
|
||||
}
|
||||
for _, u := range m.Libraries {
|
||||
if used[u.Library] == nil {
|
||||
used[u.Library] = map[string]bool{}
|
||||
}
|
||||
used[u.Library][filepath.Clean(u.Path)] = true
|
||||
if a := r.toolsAccount(m, with); a != "" && runner[u.Library] == "" {
|
||||
runner[u.Library] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
var libs []LibraryEntry
|
||||
for name, l := range with.Libraries {
|
||||
home := l.Home == r.Node
|
||||
if used[name] == nil && !home {
|
||||
continue
|
||||
}
|
||||
gid, has := with.LibraryGroups[name]
|
||||
if !has || !l.Reaches(r.Node) {
|
||||
continue
|
||||
}
|
||||
paths := map[string]bool{}
|
||||
for p := range used[name] {
|
||||
paths[p] = true
|
||||
}
|
||||
if home {
|
||||
// On its home the engine keeps every sub-path any module asks for, wherever it runs (§6).
|
||||
for _, p := range with.LibraryPaths[name] {
|
||||
paths[filepath.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
e := LibraryEntry{Library: name, Root: l.Root, Group: l.Group(), GID: gid, Home: home,
|
||||
Taken: with.TakenLibraries[name], Paths: []string{}, Runner: runner[name]}
|
||||
for p := range paths {
|
||||
e.Paths = append(e.Paths, p)
|
||||
}
|
||||
sort.Strings(e.Paths)
|
||||
libs = append(libs, e)
|
||||
}
|
||||
sort.Slice(libs, func(i, j int) bool { return libs[i].Library < libs[j].Library })
|
||||
return groups, ids, libs
|
||||
}
|
||||
|
||||
// LibraryUseShape is a module's libraries and whether it joins, in a form that compares: a move that changes either
|
||||
// changes who uses its libraries (the gate's reading of an unknown or a held library).
|
||||
func LibraryUseShape(m Manifest) string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
out = append(out, u.ID+"\x00"+u.Library+"\x00"+filepath.Clean(u.Path)+"\x00"+u.At())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return fmt.Sprint(Joins(m), out)
|
||||
}
|
||||
|
||||
// LibraryOfEntry is the library a module's entry id names, or "".
|
||||
func LibraryOfEntry(m Manifest, id string) string {
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == id {
|
||||
return u.Library
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||||
// definition's default; false where neither says, or the module declares no such access.
|
||||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||||
_, byID, err := accessesFor(m, layers)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path, has := byID[id]
|
||||
if !has || path == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(path), true
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0328 on the controller's side, row by row of its "How it is checked" table where the row is the
|
||||
// controller's: libraries defined (1, 13), entries resolved (2), groups and numbers (3, 4, 15), the identity and library
|
||||
// placeholders (5), a read use mounted read-only (10), the tool runner in a library's group (11), a joining module held
|
||||
// until its libraries are taken (8), and a module that does not join composed exactly as before (14).
|
||||
|
||||
const libraryImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
var mediaLibrary = Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}}
|
||||
|
||||
// aRadarr writes movies and reads books, tells its image its identity by PUID and PGID, and has tools on the runner.
|
||||
func aRadarr(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "radarr", "tools/index.js")
|
||||
m.Libraries = []LibraryUse{
|
||||
{ID: "movies", Library: "media", Path: "movies", Use: AccessReadWrite},
|
||||
{ID: "books", Library: "books", Path: "shelf", Use: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "env", "type": "file", "path": "${dir:config}/identity.env", "mode": "0644",
|
||||
"content": "PUID=${identity:uid}\nPGID=${identity:gid}\nUSER=${identity:user}\nGROUP=${identity:group}\nUMASK=002\n"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": libraryImage,
|
||||
"env": map[string]any{"PUID": "${identity:uid}", "PGID": "${identity:gid}"},
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies", "${library:books}:/books:z"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aPlain is a module that neither declares libraries nor uses its identity.
|
||||
func aPlain() Manifest {
|
||||
return Manifest{Module: "plain", Version: "1", Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700", "owner": "999:999"},
|
||||
{"id": "app", "type": "container", "name": "plain", "image": libraryImage, "volumes": []any{"${dir:data}:/data"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func librariesRendering() Rendering {
|
||||
books := Library{Name: "books", Home: "ace", Root: "/storage/books"}
|
||||
return Rendering{KeepsLibraries: true,
|
||||
Libraries: map[string]Library{"media": mediaLibrary, "books": books},
|
||||
LibraryGroups: map[string]int{"media": 50010, "books": 50011},
|
||||
Identities: map[string]ModuleIdentity{"radarr": {Module: "radarr", UID: 50020}},
|
||||
TakenLibraries: map[string]bool{"media": true, "books": true},
|
||||
LibraryPaths: map[string][]string{"media": {"movies", "series"}},
|
||||
}
|
||||
}
|
||||
|
||||
func composeLibraries(t *testing.T, r Resolution, with Rendering) Composed {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return composed
|
||||
}
|
||||
|
||||
// Rows 2, 4, 5, 10: a joining module's identity is filled everywhere, its library entries resolve to root + path in its
|
||||
// volume sources, a read entry mounts read-only, its directories are owned by its user and primary group — the first
|
||||
// library it uses — and its other libraries are added groups.
|
||||
func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, librariesRendering())
|
||||
got := byID(composed.Resources)
|
||||
|
||||
app := got["radarr.app"]
|
||||
if fmt.Sprint(app["env"]) != fmt.Sprint(map[string]any{"PUID": "50020", "PGID": "50010"}) {
|
||||
t.Errorf("the container's environment is %v", app["env"])
|
||||
}
|
||||
want := []any{"/var/lib/radarr/config:/config", "/storage/media/movies:/movies", "/storage/books/shelf:/books:ro,z"}
|
||||
if !reflect.DeepEqual(app["volumes"], want) {
|
||||
t.Errorf("the volumes are %v; want %v", app["volumes"], want)
|
||||
}
|
||||
if content := fmt.Sprint(got["radarr.env"]["content"]); content !=
|
||||
"PUID=50020\nPGID=50010\nUSER=mesh-radarr\nGROUP=mesh-media\nUMASK=002\n" {
|
||||
t.Errorf("the identity file reads %q", content)
|
||||
}
|
||||
if owner := got["radarr.config"]["owner"]; owner != "50020:50010" {
|
||||
t.Errorf("its directory is owned by %v; want its user and primary group", owner)
|
||||
}
|
||||
if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr",
|
||||
Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media", "mesh-books"}}) {
|
||||
t.Errorf("the identities are %+v", composed.Identities)
|
||||
}
|
||||
if w := LibraryWarnings(aRadarr(t)); len(w) != 1 || !strings.Contains(w[0], "reaches only media") {
|
||||
t.Errorf("a module of two libraries is warned %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 3, 7, 11: every library reaching the machine has its group, with its number; each library used here or homed
|
||||
// here is named with its root, whether this is its home, whether it is taken, the sub-paths kept on its home, and the
|
||||
// tool runner's account where a module whose tools run there uses it.
|
||||
func TestTheDeclarationNamesTheLibrariesAndTheirGroups(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.TakenLibraries = map[string]bool{"media": false, "books": true}
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, with)
|
||||
raw, _ := json.Marshal(map[string]any{"groups": composed.Groups, "libraries": composed.Libraries})
|
||||
want := `{"groups":[{"name":"mesh-books","gid":50011},{"name":"mesh-media","gid":50010}],` +
|
||||
`"libraries":[{"library":"books","root":"/storage/books","group":"mesh-books","gid":50011,"home":true,` +
|
||||
`"taken":true,"paths":["shelf"],"runner":"jochen"},` +
|
||||
`{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,"taken":false,` +
|
||||
`"paths":["movies","series"],"runner":"jochen"}]}`
|
||||
if string(raw) != want {
|
||||
t.Errorf("the declaration says\n %s\nwant\n %s", raw, want)
|
||||
}
|
||||
// On a machine it reaches but is not home: the group, the library as used there, and no sub-path from elsewhere.
|
||||
elsewhere := composeLibraries(t, Resolution{Node: "novox", Account: "jochen",
|
||||
Modules: []Manifest{aPlain(), theRuntime(t)}}, with)
|
||||
raw, _ = json.Marshal(map[string]any{"groups": elsewhere.Groups, "libraries": elsewhere.Libraries})
|
||||
if string(raw) != `{"groups":[{"name":"mesh-media","gid":50010}],"libraries":null}` {
|
||||
t.Errorf("on a machine it only reaches: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: a module assigned to a machine its library does not reach is left out there, naming the library; one whose
|
||||
// library is not defined likewise.
|
||||
func TestALibraryNotReachingTheMachineLeavesItsModuleOut(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: 50020}
|
||||
composed := composeLibraries(t, Resolution{Node: "far", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "does not reach far") {
|
||||
t.Errorf("radarr on a machine media does not reach: %q", why)
|
||||
}
|
||||
delete(with.Libraries, "books")
|
||||
composed = composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "books, which is not defined") {
|
||||
t.Errorf("radarr with an undefined library: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 8: a joining module that ran here before keeps the user it ran as until its libraries are taken — left out, its
|
||||
// containers untouched — while its libraries are still named, so the engine states the wait and the take can come.
|
||||
func TestAJoiningModuleIsHeldUntilItsLibrariesAreTaken(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.TakenLibraries["media"] = false
|
||||
with.Held = map[string]string{"radarr": "media"}
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "keeps the user it ran as until the library media is taken") {
|
||||
t.Fatalf("radarr while media is not taken: %q", why)
|
||||
}
|
||||
if byID(composed.Resources)["radarr.app"] != nil {
|
||||
t.Fatal("a held module's container was sent")
|
||||
}
|
||||
found := false
|
||||
for _, l := range composed.Libraries {
|
||||
found = found || (l.Library == "media" && !l.Taken)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the library a held module waits on is not named: %+v", composed.Libraries)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 14: a module that neither declares libraries nor uses its identity is composed exactly as before, on an engine
|
||||
// that keeps libraries or not; and a joining module on an older engine is left out, naming why.
|
||||
func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aPlain(), theRuntime(t)}}
|
||||
before := composeLibraries(t, r, Rendering{})
|
||||
after := composeLibraries(t, r, librariesRendering())
|
||||
if !reflect.DeepEqual(before.Resources, after.Resources) {
|
||||
t.Fatalf("a module that does not join composed otherwise:\n %v\n %v", before.Resources, after.Resources)
|
||||
}
|
||||
if Joins(aPlain()) || !Joins(aRadarr(t)) {
|
||||
t.Fatal("joining is not read from the libraries and the identity")
|
||||
}
|
||||
older := librariesRendering()
|
||||
older.KeepsLibraries = false
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), aPlain(), theRuntime(t)}}, older)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "older than them") {
|
||||
t.Fatalf("a joining module on an older engine: %q", why)
|
||||
}
|
||||
if len(composed.Groups)+len(composed.Identities)+len(composed.Libraries) > 0 {
|
||||
t.Fatal("an older engine was sent groups, identities or libraries")
|
||||
}
|
||||
if byID(composed.Resources)["plain.app"] == nil {
|
||||
t.Fatal("a module that does not join was left out beside one that does")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 15: no number outside the range is sent, root's above all; the composition refuses, naming why.
|
||||
func TestNoNumberTheMeshGivesIsRoot(t *testing.T) {
|
||||
for _, n := range []int{0, 4, 10, IdentityLow - 1, IdentityHigh + 1} {
|
||||
if AllocatableID(n) {
|
||||
t.Errorf("%d is allocatable", n)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: n}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a module numbered %d composed: %v", n, err)
|
||||
}
|
||||
with = librariesRendering()
|
||||
with.LibraryGroups["media"] = n
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a library group numbered %d composed: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func libraryManifest(t *testing.T, extra map[string]any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "app", "version": "1", "capabilities": []any{"container-runtime"},
|
||||
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}},
|
||||
"resources": []any{map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${library:movies}:/movies"}}}}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// Rows 2 and 5 at the module check: library entries are accepted by name, sub-path and use, and refused with a host
|
||||
// path, an owner, a group, a mode or a number; ${library:<id>} only in a volume source; ${identity:…} only by its four
|
||||
// names; a joining module names no directory owner and declares no number setting.
|
||||
func TestTheModuleCheckHoldsLibrariesAndIdentityToTheDecision(t *testing.T) {
|
||||
if _, err := ParseManifest(libraryManifest(t, nil)); err != nil {
|
||||
t.Fatalf("a library entry was refused: %v", err)
|
||||
}
|
||||
entry := func(extra map[string]any) map[string]any {
|
||||
e := map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}
|
||||
for k, v := range extra {
|
||||
e[k] = v
|
||||
}
|
||||
return map[string]any{"libraries": []any{e}}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
extra map[string]any
|
||||
want string
|
||||
}{
|
||||
"a host path": {entry(map[string]any{"path": "/storage/media/movies"}), "never a host path"},
|
||||
"out of it": {entry(map[string]any{"path": "../other"}), "never a host path"},
|
||||
"an owner": {entry(map[string]any{"owner": "1000"}), "owner"},
|
||||
"a group": {entry(map[string]any{"group": "media"}), "group"},
|
||||
"a mode": {entry(map[string]any{"mode": "0775"}), "mode"},
|
||||
"a number": {entry(map[string]any{"gid": 1000}), "gid"},
|
||||
"a use": {entry(map[string]any{"use": "write"}), "is used \"write\""},
|
||||
"in the env": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"MOVIES": "${library:movies}"}}}}, "volume source"},
|
||||
"in a file": {map[string]any{"resources": []any{map[string]any{"id": "f", "type": "file", "path": "/etc/app.conf",
|
||||
"mode": "0644", "content": "movies=${library:movies}"}}}, "volume source"},
|
||||
"on the container side": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "volumes": []any{"/srv:${library:movies}"}}}}, "volume source"},
|
||||
"an unknown identity": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"HOME": "${identity:home}"}}}}, "${identity:home}"},
|
||||
"a directory owner": {map[string]any{"resources": []any{map[string]any{"id": "d", "type": "directory",
|
||||
"mode": "0750", "owner": "1000:1000"}}}, "names its owner"},
|
||||
} {
|
||||
_, err := ParseManifest(libraryManifest(t, c.extra))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v; want refused saying %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
// A module that does not join keeps its directory owner and its settings as they are (§14).
|
||||
plain := map[string]any{"module": "plain", "version": "1",
|
||||
"resources": []any{map[string]any{"id": "d", "type": "directory", "mode": "0750", "owner": "1000:1000"}}}
|
||||
raw, _ := json.Marshal(plain)
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Errorf("a module that does not join was refused: %v", err)
|
||||
}
|
||||
// A setting carrying a number, or a placement's owner, is refused for a joining module, and kept for one that does
|
||||
// not.
|
||||
if err := JoiningSettingProblem(aRadarr(t), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err == nil ||
|
||||
!strings.Contains(err.Error(), "carries a user or group number") {
|
||||
t.Errorf("a joining module's puid setting: %v", err)
|
||||
}
|
||||
if err := JoiningSettingProblem(aPlain(), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err != nil {
|
||||
t.Errorf("a plain module's puid setting: %v", err)
|
||||
}
|
||||
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/x", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(aRadarr(t), placed); err == nil || !strings.Contains(err.Error(), "ADR 0328") {
|
||||
t.Errorf("a joining module's placed owner: %v", err)
|
||||
}
|
||||
if err := PlacesOwnerProblem(aPlain(), placed); err != nil {
|
||||
t.Errorf("a plain module's placed owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 1: the libraries are the controller's own mesh-wide setting, the terminal's or the operator's Approve alone.
|
||||
func TestTheLibrariesAreTheOperatorsSetting(t *testing.T) {
|
||||
controller := Manifest{Module: LibrariesModule, Version: "1"}
|
||||
keys := TerminalKeys(controller)
|
||||
if !hasName(keys, LibrariesSetting) {
|
||||
t.Fatalf("the libraries are not among the terminal's keys: %v", keys)
|
||||
}
|
||||
if hasName(TerminalKeys(Manifest{Module: "radarr"}), LibrariesSetting) {
|
||||
t.Fatal("another module's libraries key is the terminal's")
|
||||
}
|
||||
if err := ComposesWith(controller, []Layer{{From: "ace", Values: map[string]any{LibrariesSetting: map[string]any{}}}},
|
||||
false); err == nil {
|
||||
t.Fatal("libraries set on one machine were accepted")
|
||||
}
|
||||
if _, err := ParseLibraries(map[string]any{"media": map[string]any{"home": "ace", "root": "/etc/media"}}); err == nil {
|
||||
t.Fatal("a library in the machine's own tree was accepted")
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,9 @@ type Manifest struct {
|
||||
// `directory` resource, which the mesh creates and owns: an access is mounted and nothing
|
||||
// about it is reconciled, and several modules may name the same one without conflict.
|
||||
Accesses []Access `json:"accesses,omitempty"`
|
||||
// Libraries are the libraries this module uses, each by name, sub-path and use (novox/hq ADR 0328 §2): where a
|
||||
// library is, and who may write it, is the operator's definition and the mesh's, never the module's.
|
||||
Libraries []LibraryUse `json:"libraries,omitempty"`
|
||||
|
||||
// Computed names something in the control plane that works this module's resources out per
|
||||
// node, instead of them being fixed here.
|
||||
@@ -1860,8 +1863,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
// How each container's image is told who to run as, and what a module that says so is held to (novox/hq ADR 0325).
|
||||
problems = append(problems, identityProblems(m)...)
|
||||
// Its libraries and its identity, and what a module that joins is held to (novox/hq ADR 0328).
|
||||
problems = append(problems, libraryProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Every module is given an identity of its own, and a container that declares `identity` runs as it (novox/hq ADR
|
||||
// 0325 §2, §3, §11).
|
||||
//
|
||||
// **The manifest says how its image is told who to run as, and nothing else.** A container resource may declare
|
||||
// `identity`: `{"user": "env:PUID", "group": "env:PGID"}` for an image that reads its user and group from its
|
||||
// environment, or `{"user": "run"}` for the container runtime's own user option. It is the image's interface, as its
|
||||
// ports are. The mesh fills it in: the controller allocates the module one user and group number, mesh-wide, once,
|
||||
// from a range of its own (IdentityLow–IdentityHigh), and the node-engine makes the account `mesh-<module>` on each
|
||||
// machine the module is assigned to and tells the image those numbers.
|
||||
//
|
||||
// **The range is 50000–59999, and a module's user and group numbers are one number.** Chosen above the numbers
|
||||
// distributions give people (1000 up) and below the 60000s Debian keeps for its own daemons, and far from root's 0
|
||||
// and every root group a distribution ships (0, adm's 4, wheel's 10): no number in it is 0 or a root group's, so §11
|
||||
// holds by the range alone, and the store refuses a row outside it. Both numbers come from one sequence, so a
|
||||
// module's uid and gid are equal; a number once given is never given again, even after the module is forgotten
|
||||
// (the store keeps the row, inventory.AllocateIdentity).
|
||||
//
|
||||
// **What the controller sends, and only to an engine that reads it** (Rendering.KeepsFolders: its statement's
|
||||
// contract is link.IdentityContract or later):
|
||||
//
|
||||
// - the declaration's `identities`: [{module, name, uid, gid}] for each module of the machine that declares
|
||||
// `identity` on a container;
|
||||
// - a container's `identity` resolved: {"mode": "env", "user_env", "group_env", "uid", "gid"} or {"mode": "run",
|
||||
// "uid", "gid"};
|
||||
// - the owner of each of the module's directories a container declaring `identity` mounts: the identity's
|
||||
// uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the
|
||||
// identity, and owning it by the identity would lock its writer out;
|
||||
// - an access's `users`: the module's identity where a container of it that declares `identity` mounts the
|
||||
// access, and the tool runner's account where the module's tools run there as named, its number the engine reads by name,
|
||||
// each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access
|
||||
// to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`,
|
||||
// whether the operator let the mesh keep that folder on that machine (§8).
|
||||
//
|
||||
// To an older engine none of it is sent, and a container's `identity` is taken out: it runs as its image says, as
|
||||
// before. A module that declares no `identity` is composed exactly as before, on any engine.
|
||||
|
||||
// The range module identities are allocated from (novox/hq ADR 0325 §3, §11). The store holds the same bounds.
|
||||
const (
|
||||
IdentityLow = 50000
|
||||
IdentityHigh = 59999
|
||||
)
|
||||
|
||||
// AllocatableID says whether n is a number the mesh may give a module's identity: inside the range, so never 0
|
||||
// nor a root group's.
|
||||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||||
|
||||
// IdentityField is a container resource's field saying how its image is told who to run as.
|
||||
const IdentityField = "identity"
|
||||
|
||||
// The two ways an image is told who to run as.
|
||||
const (
|
||||
IdentityEnv = "env"
|
||||
IdentityRun = "run"
|
||||
)
|
||||
|
||||
// IdentitySpec is a container's `identity` as its manifest declares it.
|
||||
type IdentitySpec struct {
|
||||
// Mode is IdentityEnv or IdentityRun.
|
||||
Mode string
|
||||
// UserEnv and GroupEnv are the environment variables an env image reads, such as PUID and PGID.
|
||||
UserEnv, GroupEnv string
|
||||
}
|
||||
|
||||
// ModuleIdentity is one module's allocated identity: its numbers, the same on every machine.
|
||||
type ModuleIdentity struct {
|
||||
Module string
|
||||
UID int
|
||||
GID int
|
||||
}
|
||||
|
||||
// IdentityName is the account a module's identity is on every machine it is assigned to.
|
||||
func IdentityName(module string) string { return "mesh-" + module }
|
||||
|
||||
// Where an access's user comes from (novox/hq ADR 0325 §4, §5).
|
||||
const (
|
||||
FromContainer = "container"
|
||||
FromToolRunner = "tool-runner"
|
||||
)
|
||||
|
||||
// AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration
|
||||
// AccessUser): the account, its user number, the use, and where it runs. The tool runner's account is named with no
|
||||
// number: it is the machine's, and the node-engine resolves it by name, refusing one missing or root's.
|
||||
type AccessUser struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid,omitempty"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
var envName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
|
||||
|
||||
// ParseIdentity reads a container's `identity`, or refuses it in words.
|
||||
func ParseIdentity(v any) (IdentitySpec, error) {
|
||||
fields, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return IdentitySpec{}, fmt.Errorf("identity is {\"user\": \"env:<NAME>\", \"group\": \"env:<NAME>\"} or " +
|
||||
"{\"user\": \"run\"}")
|
||||
}
|
||||
for k := range fields {
|
||||
if k != "user" && k != "group" {
|
||||
return IdentitySpec{}, fmt.Errorf("identity says %q, and it says only how the image is told its user "+
|
||||
"and group: \"user\" and \"group\", never an owner or a number (%s §1, §2)", k, ADR0325)
|
||||
}
|
||||
}
|
||||
user, _ := fields["user"].(string)
|
||||
group, hasGroup := fields["group"]
|
||||
switch {
|
||||
case user == IdentityRun:
|
||||
if hasGroup {
|
||||
return IdentitySpec{}, fmt.Errorf("identity {\"user\": \"run\"} takes no group: the runtime's user " +
|
||||
"option carries both numbers")
|
||||
}
|
||||
return IdentitySpec{Mode: IdentityRun}, nil
|
||||
case strings.HasPrefix(user, "env:"):
|
||||
g, _ := group.(string)
|
||||
if !strings.HasPrefix(g, "env:") {
|
||||
return IdentitySpec{}, fmt.Errorf("identity names its user as %q and its group as %v; an image told "+
|
||||
"its user by its environment is told its group the same way, \"env:<NAME>\"", user, group)
|
||||
}
|
||||
u, gr := strings.TrimPrefix(user, "env:"), strings.TrimPrefix(g, "env:")
|
||||
if !envName.MatchString(u) || !envName.MatchString(gr) || u == gr {
|
||||
return IdentitySpec{}, fmt.Errorf("identity names the variables %q and %q; each is an environment "+
|
||||
"variable's name, upper-case letters, digits and underscores, and the two differ", u, gr)
|
||||
}
|
||||
return IdentitySpec{Mode: IdentityEnv, UserEnv: u, GroupEnv: gr}, nil
|
||||
}
|
||||
return IdentitySpec{}, fmt.Errorf("identity says its user is %q; it is \"env:<NAME>\" or \"run\"", user)
|
||||
}
|
||||
|
||||
// DeclaresIdentity says a module has a container that declares `identity`: it is given one (§3).
|
||||
func DeclaresIdentity(m Manifest) bool {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "container" {
|
||||
if _, has := r[IdentityField]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containerMounts is what a container of the manifest mounts, read from its volumes as written: the module's own
|
||||
// directories by id, and its accesses by id — or, for a literal source, as "path:<path>".
|
||||
func containerMounts(r map[string]any) (dirs, accesses []string) {
|
||||
volumes, _ := r["volumes"].([]any)
|
||||
for _, v := range volumes {
|
||||
s, _ := v.(string)
|
||||
source := volumeSource(s)
|
||||
if strings.HasPrefix(source, "/") {
|
||||
accesses = append(accesses, "path:"+filepath.Clean(source))
|
||||
continue
|
||||
}
|
||||
for _, m := range dirRef.FindAllStringSubmatch(source, -1) {
|
||||
dirs = append(dirs, m[1])
|
||||
}
|
||||
for _, m := range accessRef.FindAllStringSubmatch(source, -1) {
|
||||
accesses = append(accesses, m[1])
|
||||
}
|
||||
}
|
||||
return dirs, accesses
|
||||
}
|
||||
|
||||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||||
// "${dir:config}:/config" is "${dir:config}".
|
||||
func volumeSource(v string) string {
|
||||
depth := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r == '{' && i > 0 && v[i-1] == '$':
|
||||
depth++
|
||||
case r == '}' && depth > 0:
|
||||
depth--
|
||||
case r == ':' && depth == 0:
|
||||
return v[:i]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// AccessUsersShape is who among a module's containers uses which access as the module's identity, in a form that
|
||||
// compares: each container declaring identity with how it is told, and the accesses it mounts, sorted. Two manifests
|
||||
// with the same shape give their accesses the same container users.
|
||||
func AccessUsersShape(m Manifest) string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
v, declares := r[IdentityField]
|
||||
if fmt.Sprint(r["type"]) != "container" || !declares {
|
||||
continue
|
||||
}
|
||||
_, accesses := containerMounts(r)
|
||||
sort.Strings(accesses)
|
||||
out = append(out, fmt.Sprint(r["id"], v, accesses))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "\x01")
|
||||
}
|
||||
|
||||
// accessMode is the mode an access a container mounts was declared at, by id or by "path:<path>"; "" when the
|
||||
// manifest declares no such access.
|
||||
func accessMode(m Manifest, ref string) string {
|
||||
for _, a := range m.Accesses {
|
||||
if (a.ID != "" && a.ID == ref) || (a.Path != "" && ref == "path:"+filepath.Clean(a.Path)) {
|
||||
return a.At()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ADR0325 is how the module check names the decision its identity rules come from.
|
||||
const ADR0325 = "novox/hq ADR 0325"
|
||||
|
||||
// identityProblems is what the module check refuses of a manifest's identities (novox/hq ADR 0325 §1, §2, §3):
|
||||
//
|
||||
// - an `identity` on anything but a container, or one that is neither env:<NAME> for both nor run;
|
||||
// - **in a module that declares `identity` on a container** — it says it runs as its own identity, so it is held
|
||||
// to the decision whole: a directory of it naming an owner (§1, §3), and a container of it that declares none
|
||||
// and mounts one of the module's directories or a read-write access (§2).
|
||||
//
|
||||
// A module that declares no `identity` is warned of the last two (IdentityWarnings), never refused for them yet:
|
||||
// every registered container module mounts its own directories without one, and refusing them would stop the
|
||||
// catalogues composing before they can migrate. **This is the narrowest rule that breaks nothing registered**: no
|
||||
// registered manifest declares `identity`, so no registered module is refused anything new.
|
||||
func identityProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
v, has := r[IdentityField]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v is a %v and says identity; only a container says how its "+
|
||||
"image is told who to run as (%s §2)", m.Module, r["id"], r["type"], ADR0325))
|
||||
continue
|
||||
}
|
||||
if _, err := ParseIdentity(v); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: the container %v: %v (%s §2)", m.Module, r["id"], err, ADR0325))
|
||||
}
|
||||
}
|
||||
if !DeclaresIdentity(m) {
|
||||
return problems
|
||||
}
|
||||
return append(problems, identityRules(m)...)
|
||||
}
|
||||
|
||||
// IdentityWarnings is what the module check warns of a module that declares no `identity` (novox/hq ADR 0325): each
|
||||
// thing the decision refuses once the module migrates, said now so the catalogues can. Refusing comes when no
|
||||
// registered module still does any of it. Empty for a module that declares `identity`: it is refused these instead.
|
||||
func IdentityWarnings(m Manifest) []string {
|
||||
if DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range identityRules(m) {
|
||||
out = append(out, p+"; accepted while registered modules still do it, refused once they have moved")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityRules is every place a manifest breaks what ADR 0325 §1–§3 refuse: a directory naming an owner, and a
|
||||
// container declaring no identity that mounts the module's own directory or a read-write access.
|
||||
func identityRules(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
switch fmt.Sprint(r["type"]) {
|
||||
case "directory":
|
||||
if owner, said := r["owner"]; said {
|
||||
out = append(out, fmt.Sprintf("%s: the directory %v names its owner %v; a directory a module owns is "+
|
||||
"owned by the module's identity, and no manifest names an owner (%s §1, §3)", m.Module, r["id"],
|
||||
owner, ADR0325))
|
||||
}
|
||||
case "container":
|
||||
if _, has := r[IdentityField]; has {
|
||||
continue
|
||||
}
|
||||
dirs, accesses := containerMounts(r)
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the module's "+
|
||||
"own directory %s; only a container that declares identity mounts its module's directories, "+
|
||||
"so what writes them is the module's identity (%s §2)", m.Module, r["id"], d, ADR0325))
|
||||
}
|
||||
}
|
||||
for _, a := range accesses {
|
||||
if accessMode(m, a) == AccessReadWrite && !seen["access:"+a] {
|
||||
seen["access:"+a] = true
|
||||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the read-write "+
|
||||
"access %s; a container that runs as its image says is given no entry on a shared folder, so "+
|
||||
"it is refused write (%s §2)", m.Module, r["id"], strings.TrimPrefix(a, "path:"), ADR0325))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that declares `identity` (novox/hq ADR
|
||||
// 0325 §1, §3): its directories are owned by its identity. Nil for a module that declares none, whose placed owner
|
||||
// is still applied, and warned of by PlacesOwnerWarnings.
|
||||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||||
if !DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
if owned := placedOwners(m, layers); len(owned) > 0 {
|
||||
return fmt.Errorf("%s places %s with an owner, and declares identity: its directories are owned by its "+
|
||||
"identity, and no setting names an owner (%s §1, §3)", m.Module, strings.Join(owned, ", "), ADR0325)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlacesOwnerWarnings is a warning for each directory a `places` setting gives an owner, for a module that declares
|
||||
// no `identity` (novox/hq ADR 0325 §1): accepted while registered modules still do it, refused once they have moved.
|
||||
func PlacesOwnerWarnings(m Manifest, layers []Layer) []string {
|
||||
if DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, id := range placedOwners(m, layers) {
|
||||
out = append(out, fmt.Sprintf("warning: %s's setting places.%s.owner names an owner; a directory a module owns "+
|
||||
"is owned by the module's identity, and no setting names an owner (%s §1, §3). Accepted while "+
|
||||
"registered modules still do it, refused once they have moved", m.Module, id, ADR0325))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func placedOwners(m Manifest, layers []Layer) []string {
|
||||
placed, err := Places(m, layers)
|
||||
if err != nil {
|
||||
// empty-on-error: a placement Places refuses is refused by name where it is judged and composed; it names no owner here
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for id, p := range placed {
|
||||
if p.Owner != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// resolvedIdentity is a container's `identity` as the node-engine is told it: the mode, the variables an env image
|
||||
// reads, and the module's numbers.
|
||||
func resolvedIdentity(spec IdentitySpec, id ModuleIdentity) map[string]any {
|
||||
out := map[string]any{"mode": spec.Mode, "uid": id.UID, "gid": id.GID}
|
||||
if spec.Mode == IdentityEnv {
|
||||
out["user_env"], out["group_env"] = spec.UserEnv, spec.GroupEnv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityFor is the identity module m runs as here, and whether one is composed: a module that declares `identity`,
|
||||
// to an engine that keeps folders, with an allocation the composition was given. A number outside the range is
|
||||
// refused (§11), never sent.
|
||||
func identityFor(m Manifest, with Rendering) (ModuleIdentity, bool, error) {
|
||||
if !with.KeepsFolders || !DeclaresIdentity(m) {
|
||||
return ModuleIdentity{}, false, nil
|
||||
}
|
||||
id, allocated := with.Identities[m.Module]
|
||||
if !allocated {
|
||||
return ModuleIdentity{}, false, nil
|
||||
}
|
||||
if !AllocatableID(id.UID) || !AllocatableID(id.GID) {
|
||||
return ModuleIdentity{}, false, fmt.Errorf("%s's identity is %d:%d, outside %d–%d: no module identity is root "+
|
||||
"or a root group's, and none is sent (%s §11)", m.Module, id.UID, id.GID, IdentityLow, IdentityHigh, ADR0325)
|
||||
}
|
||||
id.Module = m.Module
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// identityInto composes one resource of module m for its identity: a container's `identity` resolved, or taken out
|
||||
// where none is composed; and the owner of a directory one of its identity's containers mounts.
|
||||
func identityInto(resource map[string]any, m Manifest, id ModuleIdentity, has bool, mountedByIdentity map[string]bool) error {
|
||||
switch fmt.Sprint(resource["type"]) {
|
||||
case "container":
|
||||
v, declared := resource[IdentityField]
|
||||
if !declared {
|
||||
return nil
|
||||
}
|
||||
if !has {
|
||||
delete(resource, IdentityField)
|
||||
return nil
|
||||
}
|
||||
spec, err := ParseIdentity(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: the container %v: %w", m.Module, resource["id"], err)
|
||||
}
|
||||
resource[IdentityField] = resolvedIdentity(spec, id)
|
||||
case "directory":
|
||||
if has && mountedByIdentity[fmt.Sprint(resource["id"])] {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", id.UID, id.GID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// identityHeld says module m's containers are not switched to its identity yet (the operator's question on ADR 0325):
|
||||
// a container declaring `identity` mounts a read-write access whose folder is neither taken (KeptFolders) nor kept by
|
||||
// the engine (ReadyFolders). Switched before the take adds the identity's entry, the app would run as the new uid and
|
||||
// lose write on the operator's existing files; held, it runs as it did (its environment's PUID and PGID), the take's
|
||||
// users already name the identity, and the switch comes in the apply after every such folder is taken.
|
||||
func identityHeld(m Manifest, with Rendering) bool {
|
||||
_, byID, err := accessesFor(m, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return true // refused where the module itself is composed; never switched on a guess
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, declares := r[IdentityField]; !declares || fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
_, refs := containerMounts(r)
|
||||
for _, ref := range refs {
|
||||
if accessMode(m, ref) != AccessReadWrite {
|
||||
continue
|
||||
}
|
||||
path := byID[ref]
|
||||
if strings.HasPrefix(ref, "path:") {
|
||||
path = strings.TrimPrefix(ref, "path:")
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path != "." && !with.KeptFolders[path] && !with.ReadyFolders[path] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// identityContainers is the composed ids of module m's containers that declare `identity`, whether or not it is sent.
|
||||
func identityContainers(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if _, declares := r[IdentityField]; declares && fmt.Sprint(r["type"]) == "container" {
|
||||
out[m.Module+"."+fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dirsMountedByIdentity is the ids of module m's directories one of its containers declaring `identity` mounts.
|
||||
func dirsMountedByIdentity(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
if _, has := r[IdentityField]; !has {
|
||||
continue
|
||||
}
|
||||
dirs, _ := containerMounts(r)
|
||||
for _, d := range dirs {
|
||||
out[d] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identitiesOf is the declaration's `identities`, by module.
|
||||
func identitiesOf(ids []ModuleIdentity) []map[string]any {
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i].Module < ids[j].Module })
|
||||
out := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"module": id.Module, "name": IdentityName(id.Module), "uid": id.UID,
|
||||
"gid": id.GID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity
|
||||
// where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the
|
||||
// module's tools run as it, each at the access's use (§4: the engine keeps the widest).
|
||||
func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string,
|
||||
with Rendering) {
|
||||
// The containers that declare identity, whether or not it is sent yet: the take adds the entries of the identity
|
||||
// to come, so they exist before the switch.
|
||||
declaring := identityContainers(m)
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module && declaring[fmt.Sprint(res["id"])] && has {
|
||||
containers = append(containers, res)
|
||||
}
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module {
|
||||
continue
|
||||
}
|
||||
path, _ := res["path"].(string)
|
||||
mode, _ := res["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = AccessRead
|
||||
}
|
||||
users := []AccessUser{}
|
||||
for _, c := range containers {
|
||||
if mounts(c, path) {
|
||||
users = append(users, AccessUser{Identity: IdentityName(m.Module), UID: id.UID, Use: mode, From: FromContainer})
|
||||
break // one identity per module, whichever of its containers mounts it
|
||||
}
|
||||
}
|
||||
if account != "" {
|
||||
users = append(users, AccessUser{Identity: account, Use: mode, From: FromToolRunner})
|
||||
}
|
||||
if len(users) > 0 {
|
||||
res["users"] = users
|
||||
}
|
||||
res["taken"] = with.KeptFolders[filepath.Clean(path)]
|
||||
}
|
||||
}
|
||||
|
||||
// mounts says whether a composed container mounts path, or something below it, from the machine. A path that merely
|
||||
// begins with the same letters — /storage/media2 beside /storage/media — is another path.
|
||||
func mounts(container map[string]any, path string) bool {
|
||||
volumes, _ := container["volumes"].([]any)
|
||||
path = filepath.Clean(path)
|
||||
for _, v := range volumes {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _, _ := strings.Cut(s, ":")
|
||||
if !strings.HasPrefix(source, "/") {
|
||||
continue // a named volume: the runtime's, not the operator's path
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if source == path || strings.HasPrefix(source, path+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||||
// definition's default; false where neither says, or the module declares no such access.
|
||||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||||
_, byID, err := accessesFor(m, layers)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path, has := byID[id]
|
||||
if !has || path == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(path), true
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0325 on the controller's side, row by row of its "How it is checked" table: the module check
|
||||
// (rows 1, 2), the identity composed (row 3), the tool runner's entry (row 4), no identity root (row 11) — and none
|
||||
// of it sent to a node-engine older than the contract, which would refuse the whole declaration.
|
||||
|
||||
const identityImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// aLibrarian files films: a container told its identity by PUID and PGID that mounts its config and the movies it
|
||||
// writes; a sidecar that declares none and reads the music; a directory only its tools write; and tools served by the
|
||||
// machine's runtime.
|
||||
func aLibrarian(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "librarian", "tools/index.js")
|
||||
m.Accesses = []Access{
|
||||
{ID: "movies", Path: "/storage/media/movies", Mode: AccessReadWrite},
|
||||
{ID: "music", Path: "/storage/media/music", Mode: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "notes", "type": "directory", "mode": "0700"},
|
||||
{"id": "app", "type": "container", "name": "librarian", "image": identityImage,
|
||||
"identity": map[string]any{"user": "env:PUID", "group": "env:PGID"},
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}},
|
||||
{"id": "sidecar", "type": "container", "name": "librarian-sidecar", "image": identityImage,
|
||||
"volumes": []any{"${access:music}:/music:ro"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aPlayer reads the movies the librarian writes, as its own identity through the runtime's user option, and has
|
||||
// tools of its own that read them too.
|
||||
func aPlayer(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "player", "tools/index.js")
|
||||
m.Accesses = []Access{{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "player", "image": identityImage,
|
||||
"identity": map[string]any{"user": "run"}, "volumes": []any{"${access:movies}:/movies:ro"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func keepingFolders(ids map[string]ModuleIdentity, kept ...string) Rendering {
|
||||
with := Rendering{KeepsFolders: true, Identities: ids, KeptFolders: map[string]bool{}}
|
||||
for _, k := range kept {
|
||||
with.KeptFolders[k] = true
|
||||
}
|
||||
return with
|
||||
}
|
||||
|
||||
func composeFolders(t *testing.T, r Resolution, with Rendering) Composed {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return composed
|
||||
}
|
||||
|
||||
var identities = map[string]ModuleIdentity{"librarian": {UID: 50001, GID: 50001}, "player": {UID: 50002, GID: 50002}}
|
||||
|
||||
// Row 3: the identity composed — the same on every machine, the container told it, the module's directories its
|
||||
// containers mount owned by it, and the declaration's identities naming the account.
|
||||
func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) {
|
||||
var seen []string
|
||||
for _, node := range []string{"ace", "novox"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
composed := composeFolders(t, r, keepingFolders(identities, "/storage/media/movies")) // its read-write folder taken: switched
|
||||
got := byID(composed.Resources)
|
||||
|
||||
app := got["librarian.app"]
|
||||
ident, _ := app[IdentityField].(map[string]any)
|
||||
want := map[string]any{"mode": "env", "user_env": "PUID", "group_env": "PGID", "uid": 50001, "gid": 50001}
|
||||
if fmt.Sprint(ident) != fmt.Sprint(want) {
|
||||
t.Errorf("%s: the container is told %v; want %v", node, ident, want)
|
||||
}
|
||||
player, _ := got["player.app"][IdentityField].(map[string]any)
|
||||
if fmt.Sprint(player) != fmt.Sprint(map[string]any{"mode": "run", "uid": 50002, "gid": 50002}) {
|
||||
t.Errorf("%s: the player is told %v", node, player)
|
||||
}
|
||||
if _, has := got["librarian.sidecar"][IdentityField]; has {
|
||||
t.Errorf("%s: a container declaring no identity was given one", node)
|
||||
}
|
||||
if owner := got["librarian.config"]["owner"]; owner != "50001:50001" {
|
||||
t.Errorf("%s: the directory its identity's container mounts is owned by %v", node, owner)
|
||||
}
|
||||
if owner, has := got["librarian.notes"]["owner"]; has {
|
||||
t.Errorf("%s: a directory no identity container mounts was given the owner %v: what writes it is not "+
|
||||
"the identity", node, owner)
|
||||
}
|
||||
raw, _ := json.Marshal(composed.Identities)
|
||||
seen = append(seen, string(raw))
|
||||
if !strings.Contains(string(raw), `"name":"mesh-librarian"`) || !strings.Contains(string(raw), `"uid":50001`) {
|
||||
t.Errorf("%s: the declaration's identities are %s", node, raw)
|
||||
}
|
||||
}
|
||||
// What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused.
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
with := keepingFolders(identities, "/storage/media/movies")
|
||||
composed := composeFolders(t, r, with)
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
|
||||
"identities": composed.Identities})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the pinned node-engine refuses it: %v", problems)
|
||||
}
|
||||
if seen[0] != seen[1] {
|
||||
t.Fatalf("the identities differ between machines: %s and %s", seen[0], seen[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring
|
||||
// identity mounts it, never a container declaring none — and the tool runner's account by name, with no number,
|
||||
// each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so
|
||||
// the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken.
|
||||
func TestAnAccessNamesItsUsers(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
with := keepingFolders(identities, "/storage/media/movies")
|
||||
got := byID(composeFolders(t, r, with).Resources)
|
||||
|
||||
users := func(id string) string {
|
||||
raw, _ := json.Marshal(got[id]["users"])
|
||||
return string(raw)
|
||||
}
|
||||
movies := "librarian." + AccessID("/storage/media/movies")
|
||||
if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` +
|
||||
`{"identity":"jochen","use":"read-write","from":"tool-runner"}]`; users(movies) != want {
|
||||
t.Errorf("the librarian's movies name %s; want %s", users(movies), want)
|
||||
}
|
||||
played := "player." + AccessID("/storage/media/movies")
|
||||
if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` +
|
||||
`{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(played) != want {
|
||||
t.Errorf("the player's movies name %s; want %s", users(played), want)
|
||||
}
|
||||
// The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools.
|
||||
music := "librarian." + AccessID("/storage/media/music")
|
||||
if want := `[{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(music) != want {
|
||||
t.Errorf("the music names %s; want only the tool runner's read", users(music))
|
||||
}
|
||||
if got[movies]["taken"] != true || got[music]["taken"] != false {
|
||||
t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"])
|
||||
}
|
||||
|
||||
// The runner is named with no number: the engine resolves it by name, never as uid 0 sent from here.
|
||||
if strings.Contains(users(music), `"uid"`) {
|
||||
t.Errorf("the tool runner's entry carries a number: %s", users(music))
|
||||
}
|
||||
}
|
||||
|
||||
// The contract: to an engine that does not state link.IdentityContract nothing of ADR 0325 is sent — no identities, no
|
||||
// container identity, no users, no taken, no owner from an identity — and what is sent is what the pinned node-engine
|
||||
// accepts, as before.
|
||||
func TestNothingOfTheIdentityIsSentToAnOlderEngine(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
composed := composeFolders(t, r, Rendering{Identities: identities})
|
||||
if len(composed.Identities) > 0 {
|
||||
t.Fatalf("identities were composed for an older engine: %v", composed.Identities)
|
||||
}
|
||||
for _, res := range composed.Resources {
|
||||
for _, field := range []string{IdentityField, "users", "taken"} {
|
||||
if _, has := res[field]; has {
|
||||
t.Errorf("%v carries %s for an older engine", res["id"], field)
|
||||
}
|
||||
}
|
||||
if res["id"] == "librarian.config" && res["owner"] != nil {
|
||||
t.Errorf("the directory was given the owner %v for an older engine", res["owner"])
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the pinned node-engine refuses it: %v", problems)
|
||||
}
|
||||
// An engine that keeps folders, with no allocation yet (a question asked before the first send): composed
|
||||
// without an identity, never with a made-up one.
|
||||
none := composeFolders(t, r, keepingFolders(nil))
|
||||
if len(none.Identities) > 0 || byID(none.Resources)["librarian.app"][IdentityField] != nil {
|
||||
t.Fatal("an identity was composed with no allocation")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 11: no identity outside the range is ever sent — 0 above all — and the composition says why.
|
||||
func TestNoModuleIdentityIsRoot(t *testing.T) {
|
||||
for _, n := range []int{0, 4, 10, 1000, IdentityLow - 1, IdentityHigh + 1} {
|
||||
if AllocatableID(n) {
|
||||
t.Errorf("%d is allocatable", n)
|
||||
}
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
|
||||
with := keepingFolders(map[string]ModuleIdentity{"librarian": {UID: n, GID: n}})
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§11") {
|
||||
t.Errorf("an identity of %d composed: %v", n, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int{IdentityLow, IdentityHigh} {
|
||||
if !AllocatableID(n) {
|
||||
t.Errorf("%d is not allocatable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifestJSON(t *testing.T, m map[string]any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func aContainerModule(name string, resources ...map[string]any) map[string]any {
|
||||
return map[string]any{"module": name, "version": "1", "capabilities": []any{"container-runtime"},
|
||||
"accesses": []any{map[string]any{"id": "movies", "path": "/storage/media/movies", "mode": "read-write"}},
|
||||
"resources": resources}
|
||||
}
|
||||
|
||||
// Rows 1 and 2: the module check accepts identity as env:<NAME> or run and refuses every other shape and an owner in
|
||||
// it; a module that declares identity is refused a directory naming its owner and a container declaring none that
|
||||
// mounts its own directory or a read-write access; a directory's mode is accepted.
|
||||
func TestTheModuleCheckHoldsAnIdentityModuleToTheDecision(t *testing.T) {
|
||||
ok := func(identity any) map[string]any {
|
||||
return map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
|
||||
"identity": identity, "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
}
|
||||
config := map[string]any{"id": "config", "type": "directory", "mode": "0750"}
|
||||
for _, identity := range []any{
|
||||
map[string]any{"user": "env:PUID", "group": "env:PGID"},
|
||||
map[string]any{"user": "run"},
|
||||
} {
|
||||
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(identity)))); err != nil {
|
||||
t.Errorf("identity %v refused: %v", identity, err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
identity any
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"user": "1000", "group": "1000"}, `"env:<NAME>" or "run"`},
|
||||
{map[string]any{"user": "env:PUID"}, "told its group the same way"},
|
||||
{map[string]any{"user": "run", "group": "env:PGID"}, "takes no group"},
|
||||
{map[string]any{"user": "env:puid", "group": "env:PGID"}, "environment variable's name"},
|
||||
{map[string]any{"user": "env:PUID", "group": "env:PGID", "owner": "1000:1000"}, "never an owner or a number"},
|
||||
{"run", "identity is"},
|
||||
} {
|
||||
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(c.identity))))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "ADR 0325") {
|
||||
t.Errorf("identity %v: %v, want it refused saying %q", c.identity, err, c.want)
|
||||
}
|
||||
}
|
||||
// An identity on anything but a container.
|
||||
onAFile := map[string]any{"id": "f", "type": "directory", "mode": "0750", "identity": map[string]any{"user": "run"}}
|
||||
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", onAFile))); err == nil {
|
||||
t.Error("an identity on a directory was accepted")
|
||||
}
|
||||
|
||||
// Held to the decision whole once it declares identity (§1, §2, §3).
|
||||
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
|
||||
bare := map[string]any{"id": "helper", "type": "container", "name": "helper", "image": identityImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", owned, ok(map[string]any{"user": "run"}), bare)))
|
||||
for _, want := range []string{"names its owner 1000:1000", "declares no identity and mounts the module's own directory config",
|
||||
"declares no identity and mounts the read-write access movies"} {
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("an identity module: %v, want it refused saying %q", err, want)
|
||||
}
|
||||
}
|
||||
// A setting's owner too.
|
||||
settingOwned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "${setting:puid}:${setting:pgid}"}
|
||||
identityModule := aContainerModule("app", settingOwned, ok(map[string]any{"user": "run"}))
|
||||
identityModule["settings"] = map[string]any{"puid": map[string]any{"default": "1000"}, "pgid": map[string]any{"default": "1000"}}
|
||||
if _, err := ParseManifest(manifestJSON(t, identityModule)); err == nil || !strings.Contains(err.Error(), "names its owner") {
|
||||
t.Errorf("a setting's owner in an identity module: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 1 and 2 for what is registered today: a module that declares no identity is warned, never refused — a
|
||||
// directory naming its owner, a container mounting its own directory or a read-write access — and each warning
|
||||
// names ADR 0325. A placement's owner is refused for an identity module and warned of for any other.
|
||||
func TestAModuleWithoutIdentityIsWarnedNotRefused(t *testing.T) {
|
||||
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
|
||||
bare := map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
m, err := ParseManifest(manifestJSON(t, aContainerModule("legacy", owned, bare)))
|
||||
if err != nil {
|
||||
t.Fatalf("a registered shape was refused: %v", err)
|
||||
}
|
||||
warnings := IdentityWarnings(m)
|
||||
sort.Strings(warnings)
|
||||
if len(warnings) != 3 {
|
||||
t.Fatalf("warned %d times: %q", len(warnings), warnings)
|
||||
}
|
||||
for _, w := range warnings {
|
||||
if !strings.Contains(w, "ADR 0325") || !strings.Contains(w, "refused once they have moved") {
|
||||
t.Errorf("a warning that does not name ADR 0325 or say refusal comes later: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/legacy", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(m, placed); err != nil {
|
||||
t.Errorf("a placement's owner refused for a module without identity: %v", err)
|
||||
}
|
||||
if w := PlacesOwnerWarnings(m, placed); len(w) != 1 || !strings.Contains(w[0], "places.config.owner") {
|
||||
t.Errorf("a placement's owner warned %q", w)
|
||||
}
|
||||
withIdentity := aLibrarian(t)
|
||||
placedLibrarian := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/librarian", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(withIdentity, placedLibrarian); err == nil || !strings.Contains(err.Error(), "ADR 0325") {
|
||||
t.Errorf("a placement's owner for an identity module: %v", err)
|
||||
}
|
||||
if len(IdentityWarnings(withIdentity)) != 0 {
|
||||
t.Error("an identity module was warned rather than refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A container is not switched to its identity while a read-write folder it mounts holds data the mesh has not taken
|
||||
// (the operator's question on ADR 0325): the app would run as the new uid before the take adds its entry, and lose
|
||||
// write on the existing files. Its identity is held — no `identity` sent, its directories keep their owner — while
|
||||
// the take's users already name the identity, so the entries exist before the switch; once every such folder is
|
||||
// taken (or kept by the engine), the identity is sent in one apply.
|
||||
func TestAContainerKeepsItsOldIdentityUntilItsDataIsTaken(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
|
||||
held := byID(composeFolders(t, r, keepingFolders(identities)).Resources)
|
||||
if _, has := held["librarian.app"][IdentityField]; has {
|
||||
t.Fatalf("the container was told its identity while its movies are not taken: %v", held["librarian.app"][IdentityField])
|
||||
}
|
||||
if owner, has := held["librarian.config"]["owner"]; has {
|
||||
t.Fatalf("its directory was given the owner %v while its identity is held", owner)
|
||||
}
|
||||
users, _ := json.Marshal(held["librarian."+AccessID("/storage/media/movies")]["users"])
|
||||
if !strings.Contains(string(users), `"identity":"mesh-librarian","uid":50001`) {
|
||||
t.Fatalf("the access does not name the identity to come, so the take would not add its entry: %s", users)
|
||||
}
|
||||
|
||||
for name, with := range map[string]Rendering{
|
||||
"taken": keepingFolders(identities, "/storage/media/movies"),
|
||||
"kept by the engine": func() Rendering {
|
||||
w := keepingFolders(identities)
|
||||
w.ReadyFolders = map[string]bool{"/storage/media/movies": true}
|
||||
return w
|
||||
}(),
|
||||
} {
|
||||
got := byID(composeFolders(t, r, with).Resources)
|
||||
if _, has := got["librarian.app"][IdentityField]; !has {
|
||||
t.Errorf("%s: the container is not told its identity", name)
|
||||
}
|
||||
if got["librarian.config"]["owner"] != "50001:50001" {
|
||||
t.Errorf("%s: its directory is owned by %v", name, got["librarian.config"]["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,10 @@ func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
||||
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A joining module's identity and its libraries' host paths: its tools run on the machine (ADR 0328 §5).
|
||||
if value, err = bundleLibraryFill(value, m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,10 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
|
||||
lands := settingKeysUsedBy(m)
|
||||
// The libraries are the controller's own mesh-wide setting (novox/hq ADR 0328 §1).
|
||||
if m.Module == LibrariesModule {
|
||||
lands[LibrariesSetting] = true
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
@@ -521,6 +525,22 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||
// declared (LeftOutBecause), and where a setting judged against a newer build of the module is held to still compose
|
||||
// with the one registered.
|
||||
func ComposesWith(m Manifest, layers []Layer, adopted bool) error {
|
||||
// The libraries, on the controller's own layer (novox/hq ADR 0328 §1): each well formed, and only mesh-wide.
|
||||
if m.Module == LibrariesModule {
|
||||
for _, l := range layers {
|
||||
v, has := l.Values[LibrariesSetting]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if l.From != MeshWideLayer && !l.Default {
|
||||
return fmt.Errorf("%s is the mesh's, set on the whole mesh and never on one machine (%s §1)",
|
||||
LibrariesSetting, ADR0328)
|
||||
}
|
||||
if _, err := ParseLibraries(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ func TerminalKeys(m Manifest) []string {
|
||||
}
|
||||
delete(keys, PlacesSetting)
|
||||
delete(keys, AccessesSetting)
|
||||
// The libraries say where shared data is and who may write it (novox/hq ADR 0328 §1): the operator's alone.
|
||||
if m.Module == LibrariesModule {
|
||||
keys[LibrariesSetting] = true
|
||||
}
|
||||
rest := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
rest = append(rest, k)
|
||||
|
||||
@@ -59,6 +59,12 @@ var fillers = []filler{
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
|
||||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
// A joining module's identity, wherever its resources take values; a library's host path, only in a container's
|
||||
// volumes, whose source libraryProblems holds it to (novox/hq ADR 0328 §5).
|
||||
{namespace: "identity", patterns: []*regexp.Regexp{identityRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file", "run", "command", "user", "name"}}},
|
||||
{namespace: "library", patterns: []*regexp.Regexp{libraryRef}, fields: map[string][]string{"container": {"volumes"}},
|
||||
why: "a library's path is a host path, filled only in a container's volume source; the process reads the " +
|
||||
"container side its volume gives (novox/hq ADR 0328 §5)"},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
why: "a secret is never filled into an environment variable or any other field: put it in a file the " +
|
||||
"module declares and mount that (novox/hq ADR 0086)"},
|
||||
|
||||
@@ -117,6 +117,10 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
}
|
||||
}
|
||||
}
|
||||
// A module never takes a defined library's name: mesh-<module> and mesh-<library> never collide (ADR 0328 §4).
|
||||
if err := i.LibraryNamed(ctx, m.Module); err != nil {
|
||||
return fmt.Errorf("%s is not registered: %w", m.Module, err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -929,6 +933,10 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
||||
// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that
|
||||
// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes.
|
||||
func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
// The libraries the controller's own layer defines (novox/hq ADR 0328 §1, §4, §13).
|
||||
if err := i.judgeLibraries(ctx, nodeName, module, values); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := i.declared(ctx, module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||
@@ -987,9 +995,8 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
}
|
||||
m = ahead
|
||||
}
|
||||
// A module that runs as its own identity owns its directories by it: no setting names their owner (novox/hq ADR
|
||||
// 0325 §1, §3). A module that does not yet keeps its placed owner, warned of where it is set.
|
||||
if err := catalogue.PlacesOwnerProblem(m, layers); err != nil {
|
||||
// A module that joins carries no user or group number or owner in a setting (novox/hq ADR 0328 §5).
|
||||
if err := catalogue.JoiningSettingProblem(m, layers[len(layers)-1:]); err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||
|
||||
@@ -36,17 +36,6 @@ type ResourceHealth struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
// Waits is what a waiting resource waits for the operator to give (novox/hq ADR 0283).
|
||||
Waits []Wait `json:"waits,omitempty"`
|
||||
// Entries is a kept folder's listing as the engine resolved it (novox/hq ADR 0325 §4): each user with its uid.
|
||||
Entries []KeptEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// KeptEntry is one user of a kept folder as the engine lists it, with a note beside the tool runner's.
|
||||
type KeptEntry struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret, Setting and Access
|
||||
@@ -62,10 +51,8 @@ type Wait struct {
|
||||
// Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not
|
||||
// answer the wait.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is the engine's reason for an access wait ("keep" or "disk"), ACL what its folder's filesystem says of
|
||||
// access entries ("needs-enabling", "unsupported", or empty when it allows them).
|
||||
// Reason is the engine's reason for an access wait: "keep" or "disk".
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Module identities and taken folders (novox/hq ADR 0325 §3, §8, §11; migration 0096).
|
||||
|
||||
// AllocateIdentity is module's identity, allocated now when it has none: one number from the range of its own,
|
||||
// mesh-wide, kept for ever. Two controllers allocating at once agree: the second's insert finds the first's row.
|
||||
func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) {
|
||||
if strings.TrimSpace(module) == "" {
|
||||
return catalogue.ModuleIdentity{}, errors.New("an identity is allocated for a module, and none was named")
|
||||
}
|
||||
if id, had, err := i.IdentityOf(ctx, module); err != nil || had {
|
||||
return id, err
|
||||
}
|
||||
// The number is drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never
|
||||
// a number given twice.
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid, gid)
|
||||
select $1, n, n from (select nextval('module_identity_number')::integer as n) drawn
|
||||
on conflict (module) do nothing`, module)
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given an identity: %w", module, err)
|
||||
}
|
||||
id, had, err := i.IdentityOf(ctx, module)
|
||||
if err == nil && !had {
|
||||
err = fmt.Errorf("%s was given an identity and it cannot be read back", module)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// IdentityOf is module's identity, and false when it was never allocated one.
|
||||
func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) {
|
||||
id := catalogue.ModuleIdentity{Module: module}
|
||||
err := i.store.Pool().QueryRow(ctx, `select uid, gid from module_identity where module = $1`, module).
|
||||
Scan(&id.UID, &id.GID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.ModuleIdentity{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Identities is every module's identity, by module.
|
||||
func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select module, uid, gid from module_identity`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]catalogue.ModuleIdentity{}
|
||||
for rows.Next() {
|
||||
var id catalogue.ModuleIdentity
|
||||
if err := rows.Scan(&id.Module, &id.UID, &id.GID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id.Module] = id
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TakenFolder is one folder the operator let the mesh keep on a machine (ADR 0325 §8).
|
||||
type TakenFolder struct {
|
||||
Path string
|
||||
By string
|
||||
Ask string
|
||||
Warrant string
|
||||
Said string
|
||||
// Placed is the filesystem the folder was on when taken, without its path (ADR 0325 §8).
|
||||
Placed string
|
||||
}
|
||||
|
||||
// KeptFolders is every folder taken on a machine, by its clean path.
|
||||
func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select k.path, k.taken_by, k.ask, k.warrant, k.said, k.placed from kept_folder k join node n on n.id = k.node
|
||||
where n.name = $1`, nodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]TakenFolder{}
|
||||
for rows.Next() {
|
||||
var t TakenFolder
|
||||
if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Placed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t.Path] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once per
|
||||
// filesystem: a second for the same folder on the same filesystem keeps the first, and one for the folder on another
|
||||
// filesystem (it moved, and the operator was asked again) replaces it.
|
||||
func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error {
|
||||
switch {
|
||||
case !strings.HasPrefix(t.Path, "/"):
|
||||
return fmt.Errorf("a taken folder is an absolute path, not %q", t.Path)
|
||||
case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "":
|
||||
return errors.New("a folder is taken only on the operator's warrant, and this names none")
|
||||
}
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into kept_folder (node, path, taken_by, ask, warrant, said, placed) values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (node, path) do update
|
||||
set taken_at = now(), taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant,
|
||||
said = excluded.said, placed = excluded.placed
|
||||
where kept_folder.placed is distinct from excluded.placed`, node.ID, filepath.Clean(t.Path), t.By, t.Ask,
|
||||
t.Warrant, t.Said, t.Placed)
|
||||
return err
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0325 §3, §11: a module gets one allocation, the same whoever asks and however often, from the range
|
||||
// of its own; no allocation is 0 or a root group's; a number once given is never given again.
|
||||
func TestAModuleIsGivenOneIdentityOnceAndNeverRoot(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
radarr, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if radarr != again {
|
||||
t.Fatalf("radarr was given %+v, then %+v: one allocation per module", radarr, again)
|
||||
}
|
||||
plex, err := inv.AllocateIdentity(ctx, "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []catalogue.ModuleIdentity{radarr, plex} {
|
||||
if id.UID == 0 || id.GID == 0 || !catalogue.AllocatableID(id.UID) || !catalogue.AllocatableID(id.GID) ||
|
||||
id.UID != id.GID {
|
||||
t.Errorf("%s was given %d:%d: within %d–%d, never 0, uid and gid one number", id.Module, id.UID, id.GID,
|
||||
catalogue.IdentityLow, catalogue.IdentityHigh)
|
||||
}
|
||||
}
|
||||
if radarr.UID == plex.UID {
|
||||
t.Fatalf("radarr and plex share %d", plex.UID)
|
||||
}
|
||||
|
||||
// Read back as composition reads it, from another connection's view: the same numbers (stable across restarts
|
||||
// and machines, since every machine's composition reads this one row).
|
||||
all, err := inv.Identities(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all["radarr"].UID != radarr.UID || all["plex"].UID != plex.UID {
|
||||
t.Fatalf("the identities read back are %+v", all)
|
||||
}
|
||||
|
||||
// A module forgotten keeps its row, and the next module is given a new number: never one given before.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sonarr, err := inv.AllocateIdentity(ctx, "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sonarr.UID == plex.UID || sonarr.UID == radarr.UID {
|
||||
t.Fatalf("sonarr was given %d, a number given before", sonarr.UID)
|
||||
}
|
||||
|
||||
// The store itself refuses a number outside the range, root's above all: no write by hand makes one root.
|
||||
for _, n := range []int{0, 10, 1000, 49999, 60000} {
|
||||
if _, err := inv.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid, gid) values ($1, $2, $2)`, "byhand", n); err == nil {
|
||||
t.Errorf("the store took %d as an identity", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0325 §8: a folder taken is recorded once per machine, under a warrant, and read back by its path.
|
||||
func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies/", By: "the operator"}); err == nil {
|
||||
t.Fatal("a folder was recorded taken with no warrant")
|
||||
}
|
||||
first := TakenFolder{Path: "/storage/media/movies/", By: "the operator", Ask: "c01", Warrant: "c01", Said: "started"}
|
||||
if err := inv.RecordTaken(ctx, "ace", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "someone else", Ask: "c02",
|
||||
Warrant: "c02"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := inv.KeptFolders(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, has := kept["/storage/media/movies"]
|
||||
if !has || got.Ask != "c01" || got.By != "the operator" {
|
||||
t.Fatalf("the taken folder reads %+v (%v): the first take, by its clean path", got, has)
|
||||
}
|
||||
// The folder moved to another filesystem and the operator was asked again: the new take replaces the old.
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "the operator", Ask: "c03",
|
||||
Warrant: "c03", Placed: "fs-0000000000ff"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err = inv.KeptFolders(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := kept["/storage/media/movies"]; got.Ask != "c03" || got.Placed != "fs-0000000000ff" {
|
||||
t.Fatalf("after a take on another filesystem the folder reads %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
|
||||
// the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused.
|
||||
// Without such a build it is refused, as before.
|
||||
func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "media-importer", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x",
|
||||
"volumes": []any{"${access:downloads}:/downloads"}}}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c0ffee33"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
|
||||
err := inv.JudgeSettings(ctx, "ace", "media-importer", placing)
|
||||
if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) {
|
||||
t.Fatalf("a placement no build declares: %v", err)
|
||||
}
|
||||
|
||||
// main's manifest — the build of #34 — declares movies.
|
||||
ahead := registered
|
||||
ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...)
|
||||
ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies",
|
||||
Mode: catalogue.AccessReadWrite})
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("b34", "media-importer", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the layer placing movies was not stored: %v", err)
|
||||
}
|
||||
// Stored ahead, it does not cost the registered module its place: composition passes it by.
|
||||
layers, err := inv.SettingsFor(ctx, "ace", "media-importer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalogue.ComposesWith(registered, layers, false); err != nil {
|
||||
t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err)
|
||||
}
|
||||
// A typo is still a typo: an access neither build declares is refused.
|
||||
typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil {
|
||||
t.Fatal("a placement no build declares was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one
|
||||
// from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so.
|
||||
func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "importer", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}}
|
||||
ahead := registered
|
||||
ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, ref := range []string{"feature", ""} {
|
||||
b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "")
|
||||
b.Ref, b.Manifest = ref, raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil {
|
||||
t.Fatal("a build of another branch, or of none, judged the placement")
|
||||
}
|
||||
// Registered by hand, with no branch: refused, saying why.
|
||||
byHand := registered
|
||||
byHand.Module = "byhand"
|
||||
if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aheadByHand := ahead
|
||||
aheadByHand.Module = "byhand"
|
||||
if raw, err = json.Marshal(aheadByHand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("onmain", "byhand", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil ||
|
||||
!strings.Contains(err.Error(), "follows no branch") {
|
||||
t.Fatalf("a module with no branch recorded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Joining modules' numbers, library groups, taken libraries and the libraries themselves (novox/hq ADR 0328 §1, §3,
|
||||
// §4, §7, §15; migration 0096).
|
||||
|
||||
// AllocateIdentity is a joining module's number, allocated now when it has none: one number from the mesh's range,
|
||||
// mesh-wide, kept for ever, its user's and its own group's. Two controllers allocating at once agree: the second's
|
||||
// insert finds the first's row.
|
||||
func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) {
|
||||
if strings.TrimSpace(module) == "" {
|
||||
return catalogue.ModuleIdentity{}, errors.New("a number is allocated for a module, and none was named")
|
||||
}
|
||||
if id, had, err := i.IdentityOf(ctx, module); err != nil || had {
|
||||
return id, err
|
||||
}
|
||||
// Drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never a number given
|
||||
// twice.
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid) select $1, nextval('mesh_number')::integer
|
||||
on conflict (module) do nothing`, module); err != nil {
|
||||
return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given its number: %w", module, err)
|
||||
}
|
||||
id, had, err := i.IdentityOf(ctx, module)
|
||||
if err == nil && !had {
|
||||
err = fmt.Errorf("%s was given its number and it cannot be read back", module)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// IdentityOf is a module's number, and false when it was never allocated one.
|
||||
func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) {
|
||||
id := catalogue.ModuleIdentity{Module: module}
|
||||
err := i.store.Pool().QueryRow(ctx, `select uid from module_identity where module = $1`, module).Scan(&id.UID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.ModuleIdentity{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Identities is every joining module's number, by module.
|
||||
func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select module, uid from module_identity`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]catalogue.ModuleIdentity{}
|
||||
for rows.Next() {
|
||||
var id catalogue.ModuleIdentity
|
||||
if err := rows.Scan(&id.Module, &id.UID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id.Module] = id
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AllocateLibraryGroup is a library's group number, allocated now when it has none, from the same sequence as the
|
||||
// modules' numbers (§3).
|
||||
func (i *Inventory) AllocateLibraryGroup(ctx context.Context, library string) (int, error) {
|
||||
if strings.TrimSpace(library) == "" {
|
||||
return 0, errors.New("a group number is allocated for a library, and none was named")
|
||||
}
|
||||
groups, err := i.LibraryGroups(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if gid, had := groups[library]; had {
|
||||
return gid, nil
|
||||
}
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into library_group (library, gid) select $1, nextval('mesh_number')::integer
|
||||
on conflict (library) do nothing`, library); err != nil {
|
||||
return 0, fmt.Errorf("the library %s could not be given its group's number: %w", library, err)
|
||||
}
|
||||
if groups, err = i.LibraryGroups(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gid, has := groups[library]
|
||||
if !has {
|
||||
return 0, fmt.Errorf("the library %s was given its group's number and it cannot be read back", library)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
// LibraryGroups is every library's group number, by library.
|
||||
func (i *Inventory) LibraryGroups(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select library, gid from library_group`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var gid int
|
||||
if err := rows.Scan(&name, &gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = gid
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Libraries is every library the operator defined (§1): the mesh-wide `libraries` layer of the controller's module.
|
||||
// None when it was never set.
|
||||
func (i *Inventory) Libraries(ctx context.Context) (map[string]catalogue.Library, error) {
|
||||
var raw []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select values from settings where module = $1 and node is null`, catalogue.LibrariesModule).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var layer map[string]any
|
||||
if err := json.Unmarshal(raw, &layer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalogue.ParseLibraries(layer[catalogue.LibrariesSetting])
|
||||
}
|
||||
|
||||
// judgeLibraries is what a layer of the controller's module is held to when it defines libraries (§1, §4, §13): only
|
||||
// mesh-wide, well formed, no library named as a registered module, none overlapping another on a machine.
|
||||
func (i *Inventory) judgeLibraries(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
v, defines := values[catalogue.LibrariesSetting]
|
||||
if module != catalogue.LibrariesModule || !defines {
|
||||
return nil
|
||||
}
|
||||
if nodeName != "" {
|
||||
return fmt.Errorf("refused: %s is the mesh's, set on the whole mesh and never on one machine (%s §1)",
|
||||
catalogue.LibrariesSetting, catalogue.ADR0328)
|
||||
}
|
||||
libs, err := catalogue.ParseLibraries(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
shelf, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modules := make([]string, 0, len(shelf))
|
||||
for name := range shelf {
|
||||
modules = append(modules, name)
|
||||
}
|
||||
if problems := catalogue.LibrariesProblems(libs, modules); len(problems) > 0 {
|
||||
return fmt.Errorf("refused: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibraryNamed refuses a module whose name is a defined library's (§4): mesh-<module> and mesh-<library> never collide.
|
||||
func (i *Inventory) LibraryNamed(ctx context.Context, module string) error {
|
||||
libs, err := i.Libraries(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, clash := libs[module]; clash {
|
||||
return fmt.Errorf("the module %s has the name of the library %s: %s would be both (%s §4)", module, module,
|
||||
catalogue.MeshName(module), catalogue.ADR0328)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibraryPaths is every sub-path a module assigned anywhere asks of each library (§6): what its home keeps.
|
||||
func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, error) {
|
||||
shelf, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx, `select distinct module from assignment`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]map[string]bool{}
|
||||
for rows.Next() {
|
||||
var module string
|
||||
if err := rows.Scan(&module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range shelf[module].Libraries {
|
||||
if seen[u.Library] == nil {
|
||||
seen[u.Library] = map[string]bool{}
|
||||
}
|
||||
seen[u.Library][filepath.Clean(u.Path)] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for lib, paths := range seen {
|
||||
for p := range paths {
|
||||
out[lib] = append(out[lib], p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TakenLibrary is one library the operator let the mesh take, on its home (§7).
|
||||
type TakenLibrary struct {
|
||||
Library string
|
||||
Home string
|
||||
Placed string
|
||||
By string
|
||||
Ask string
|
||||
Warrant string
|
||||
Said string
|
||||
}
|
||||
|
||||
// TakenLibraries is every library taken, by name.
|
||||
func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select library, home, placed, taken_by, ask, warrant, said from kept_library`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]TakenLibrary{}
|
||||
for rows.Next() {
|
||||
var t TakenLibrary
|
||||
if err := rows.Scan(&t.Library, &t.Home, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t.Library] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordTaken keeps that a library was taken on its home, under the operator's warrant. Once per home and filesystem:
|
||||
// a second take of the same keeps the first, and one on another home or filesystem (it moved, and the operator was
|
||||
// asked again) replaces it.
|
||||
func (i *Inventory) RecordTaken(ctx context.Context, t TakenLibrary) error {
|
||||
switch {
|
||||
case t.Library == "" || t.Home == "":
|
||||
return errors.New("a taken library names itself and its home")
|
||||
case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "":
|
||||
return errors.New("a library is taken only on the operator's warrant, and this names none")
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`insert into kept_library (library, home, placed, taken_by, ask, warrant, said)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (library) do update
|
||||
set home = excluded.home, placed = excluded.placed, taken_at = now(), taken_by = excluded.taken_by,
|
||||
ask = excluded.ask, warrant = excluded.warrant, said = excluded.said
|
||||
where kept_library.home <> excluded.home or kept_library.placed <> excluded.placed`,
|
||||
t.Library, t.Home, t.Placed, t.By, t.Ask, t.Warrant, t.Said)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0328 §3, §4, §15: one number per joining module (its user's and its own group's) and one per library's
|
||||
// group, from one sequence and one range — never 0 or a root group's, never shared between a module and a library,
|
||||
// never given twice, the same however often asked.
|
||||
func TestOneNumberPerModuleAndLibraryNeverRoot(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
radarr, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil || again != radarr {
|
||||
t.Fatalf("radarr was given %+v, then %+v (%v): one number per module", radarr, again, err)
|
||||
}
|
||||
media, err := inv.AllocateLibraryGroup(ctx, "media")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mediaAgain, err := inv.AllocateLibraryGroup(ctx, "media"); err != nil || mediaAgain != media {
|
||||
t.Fatalf("the media group was given %d, then %d (%v)", media, mediaAgain, err)
|
||||
}
|
||||
plex, err := inv.AllocateIdentity(ctx, "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
for who, n := range map[string]int{"radarr": radarr.UID, "plex": plex.UID, "mesh-media": media} {
|
||||
if n == 0 || !catalogue.AllocatableID(n) {
|
||||
t.Errorf("%s was given %d: within %d–%d, never 0", who, n, catalogue.IdentityLow, catalogue.IdentityHigh)
|
||||
}
|
||||
if other, twice := seen[n]; twice {
|
||||
t.Errorf("%s and %s share %d: a module's own group and a library's never do", who, other, n)
|
||||
}
|
||||
seen[n] = who
|
||||
}
|
||||
// Read back as every machine's composition reads them: the same numbers.
|
||||
ids, err := inv.Identities(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups, err := inv.LibraryGroups(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ids["radarr"].UID != radarr.UID || groups["media"] != media {
|
||||
t.Fatalf("read back %+v and %v", ids, groups)
|
||||
}
|
||||
// A forgotten row frees no number: the next one is new.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sonarr, err := inv.AllocateIdentity(ctx, "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, given := seen[sonarr.UID]; given {
|
||||
t.Fatalf("sonarr was given %d, a number given before", sonarr.UID)
|
||||
}
|
||||
// The store refuses a number outside the range, root's above all.
|
||||
for _, n := range []int{0, 4, 10, 1000, 49999, 60000} {
|
||||
if _, err := inv.store.Pool().Exec(ctx, `insert into module_identity (module, uid) values ('byhand', $1)`, n); err == nil {
|
||||
t.Errorf("the store took %d as a module's number", n)
|
||||
}
|
||||
if _, err := inv.store.Pool().Exec(ctx, `insert into library_group (library, gid) values ('byhand', $1)`, n); err == nil {
|
||||
t.Errorf("the store took %d as a library group's number", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// libraryController registers the controller's own module, whose mesh-wide layer defines the libraries.
|
||||
func libraryController(t *testing.T, inv *Inventory) {
|
||||
t.Helper()
|
||||
m := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "controller", "image": "x"}}}
|
||||
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §1, §4, §13: a library is defined only on the controller's mesh-wide layer, as a trusted setting;
|
||||
// never on one machine; never overlapping another on a machine it reaches; never named as a registered module — and no
|
||||
// module is registered under a defined library's name.
|
||||
func TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
for _, n := range []string{"ace", "novox"} {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
libraryController(t, inv)
|
||||
radarr := catalogue.Manifest{Module: "radarr", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "radarr", "image": "x"}}}
|
||||
if err := inv.RegisterModule(ctx, radarr, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := map[string]any{"home": "ace", "root": "/storage/media", "reach": []any{"novox"}}
|
||||
define := func(libs map[string]any) map[string]any {
|
||||
return map[string]any{catalogue.LibrariesSetting: libs}
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, define(map[string]any{"media": media})); err != nil {
|
||||
t.Fatalf("a library was not defined: %v", err)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := libs["media"]; l.Home != "ace" || l.Root != "/storage/media" || !l.Reaches("novox") || !l.Reaches("ace") {
|
||||
t.Fatalf("the library reads %+v", libs["media"])
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
node string
|
||||
libs map[string]any
|
||||
want string
|
||||
}{
|
||||
"on one machine": {"ace", map[string]any{"media": media}, "never on one machine"},
|
||||
"inside another": {"", map[string]any{"media": media,
|
||||
"movies": map[string]any{"home": "novox", "root": "/storage/media/movies"}}, "overlap on"},
|
||||
"around another": {"", map[string]any{"movies": map[string]any{"home": "ace", "root": "/storage/media/movies"},
|
||||
"media": media}, "overlap on"},
|
||||
"named as a module": {"", map[string]any{"radarr": map[string]any{"home": "ace", "root": "/srv/radarr-data"}},
|
||||
"name of the module radarr"},
|
||||
"with an owner": {"", map[string]any{"books": map[string]any{"home": "ace", "root": "/srv/books",
|
||||
"owner": "1000"}}, "nothing about an owner"},
|
||||
} {
|
||||
err := inv.SetSettings(ctx, c.node, catalogue.LibrariesModule, define(c.libs))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v; want refused saying %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
// Two libraries apart on different machines may share a path's prefix only where they do not meet.
|
||||
apart := map[string]any{"media": media, "photos": map[string]any{"home": "elsewhere", "root": "/storage/media/photos"}}
|
||||
if err := inv.JudgeSettings(ctx, "", catalogue.LibrariesModule, define(apart)); err != nil {
|
||||
t.Errorf("libraries that meet on no machine were refused: %v", err)
|
||||
}
|
||||
// A module under a defined library's name is refused.
|
||||
named := catalogue.Manifest{Module: "media", Version: "1",
|
||||
Resources: []map[string]any{{"id": "s", "type": "container", "name": "media", "image": "x"}}}
|
||||
if err := inv.RegisterModule(ctx, named, Source{}); err == nil || !strings.Contains(err.Error(), "name of the library media") {
|
||||
t.Fatalf("a module named as a library: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §7: a library taken is recorded once per home and filesystem, under a warrant; one taken again on
|
||||
// another filesystem replaces it.
|
||||
func TestALibraryIsTakenOnceUnderAWarrant(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if err := inv.RecordTaken(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil {
|
||||
t.Fatal("a library was recorded taken with no warrant")
|
||||
}
|
||||
first := TakenLibrary{Library: "media", Home: "ace", Placed: "fs-000000000001", By: "the operator", Ask: "c01",
|
||||
Warrant: "c01"}
|
||||
if err := inv.RecordTaken(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := first
|
||||
again.Ask, again.Warrant, again.By = "c02", "c02", "someone else"
|
||||
if err := inv.RecordTaken(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := inv.TakenLibraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := taken["media"]; got.Ask != "c01" {
|
||||
t.Fatalf("a second take of the same overwrote the first: %+v", got)
|
||||
}
|
||||
moved := first
|
||||
moved.Placed, moved.Ask, moved.Warrant = "fs-0000000000ff", "c03", "c03"
|
||||
if err := inv.RecordTaken(ctx, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c03" {
|
||||
t.Fatalf("a take on another filesystem: %+v (%v)", taken["media"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
|
||||
// the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused.
|
||||
// Without such a build it is refused, as before.
|
||||
func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "media-importer", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x",
|
||||
"volumes": []any{"${access:downloads}:/downloads"}}}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c0ffee33"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
|
||||
err := inv.JudgeSettings(ctx, "ace", "media-importer", placing)
|
||||
if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) {
|
||||
t.Fatalf("a placement no build declares: %v", err)
|
||||
}
|
||||
|
||||
// main's manifest — the build of #34 — declares movies.
|
||||
ahead := registered
|
||||
ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...)
|
||||
ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies",
|
||||
Mode: catalogue.AccessReadWrite})
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("b34", "media-importer", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the layer placing movies was not stored: %v", err)
|
||||
}
|
||||
// Stored ahead, it does not cost the registered module its place: composition passes it by.
|
||||
layers, err := inv.SettingsFor(ctx, "ace", "media-importer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalogue.ComposesWith(registered, layers, false); err != nil {
|
||||
t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err)
|
||||
}
|
||||
// A typo is still a typo: an access neither build declares is refused.
|
||||
typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil {
|
||||
t.Fatal("a placement no build declares was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one
|
||||
// from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so.
|
||||
func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "importer", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}}
|
||||
ahead := registered
|
||||
ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, ref := range []string{"feature", ""} {
|
||||
b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "")
|
||||
b.Ref, b.Manifest = ref, raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil {
|
||||
t.Fatal("a build of another branch, or of none, judged the placement")
|
||||
}
|
||||
// Registered by hand, with no branch: refused, saying why.
|
||||
byHand := registered
|
||||
byHand.Module = "byhand"
|
||||
if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aheadByHand := ahead
|
||||
aheadByHand.Module = "byhand"
|
||||
if raw, err = json.Marshal(aheadByHand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("onmain", "byhand", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil ||
|
||||
!strings.Contains(err.Error(), "follows no branch") {
|
||||
t.Fatalf("a module with no branch recorded: %v", err)
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
-- Every module is given an identity of its own, and the mesh takes a shared folder once, on the operator's word
|
||||
-- (novox/hq ADR 0325 §3, §8, §11).
|
||||
--
|
||||
-- **One number per module, mesh-wide, allocated once and never again.** The node-engine makes the account
|
||||
-- mesh-<module> on each machine the module is assigned to, with these numbers, so a container that declares
|
||||
-- `identity` runs as the same uid and gid everywhere and a folder's entries mean the same on every machine. The
|
||||
-- numbers come from a sequence of their own, 50000–59999 (catalogue.IdentityLow and IdentityHigh): above the
|
||||
-- numbers distributions give people, below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a
|
||||
-- root group's (0, 4, 10). The checks refuse a row outside the range, so no write, by hand or by a later bug, can make
|
||||
-- an identity root (§11). uid and gid are one number. A sequence never hands a number back, and a row is kept after
|
||||
-- its module is forgotten, so a number once given is never given to another module: a file left owned by a removed
|
||||
-- module's identity is never inherited by a new one.
|
||||
create sequence module_identity_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle;
|
||||
|
||||
create table module_identity (
|
||||
module text primary key,
|
||||
uid integer not null unique check (uid between 50000 and 59999),
|
||||
gid integer not null unique check (gid between 50000 and 59999),
|
||||
allocated timestamptz not null default now(),
|
||||
check (uid = gid)
|
||||
);
|
||||
|
||||
-- **A folder taken, per machine** (§8). The first time a placed folder that already holds data is to be kept, the
|
||||
-- operator is asked once for that folder on that machine; on the warrant the node-engine adds the entries to
|
||||
-- everything there, and the folder is taken: kept from then on without asking, a module that gains the access
|
||||
-- added with no wait. The row is the record of that act — who approved it, under which warrant — and what tells a
|
||||
-- later module's access that it joins a taken folder (`taken` on the access). The path is the folder as placed,
|
||||
-- clean and absolute. A new warrant for the same folder on another filesystem replaces the row.
|
||||
create table kept_folder (
|
||||
node uuid not null references node (id) on delete cascade,
|
||||
path text not null check (path like '/%'),
|
||||
taken_at timestamptz not null default now(),
|
||||
-- Who approved it, as the hand-act log says it, and the ask and warrant it was taken under.
|
||||
taken_by text not null,
|
||||
ask text not null,
|
||||
warrant text not null,
|
||||
-- What the node-engine answered when it started the take.
|
||||
said text not null default '',
|
||||
-- The filesystem the folder was on when it was taken, as the engine names it without a path ("fs-<12 hex>"):
|
||||
-- a wait naming another (the folder moved to another disk) is not answered by this take, and the operator is
|
||||
-- asked again; until the new warrant the folder is not taken there. Empty from an engine that names none.
|
||||
placed text not null default '',
|
||||
primary key (node, path)
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- A module that joins has a user and a group of its own, each library has a group of its own, and a library's
|
||||
-- existing data is taken once, on the operator's word (novox/hq ADR 0328 §3, §4, §7, §15).
|
||||
--
|
||||
-- **One sequence for every number the mesh gives.** A joining module is given one number, its user's and its own
|
||||
-- group's (mesh-<module>); a library is given one, its group's (mesh-<library>). Both are drawn from this sequence, so
|
||||
-- a module's own group and a library's group never share a number. The range is 50000–59999 (catalogue.IdentityLow and
|
||||
-- IdentityHigh): above the numbers distributions give people, below the 60000s Debian keeps for its daemons, and
|
||||
-- nowhere near root's 0 or a root group's (0, 4, 10). The checks refuse a row outside it, so no write, by hand or by a
|
||||
-- later bug, can make one root's (§15). A sequence never hands a number back, and a row is kept after its module or
|
||||
-- library is gone, so a number once given is never given to another: a file left owned by a removed module's user is
|
||||
-- never inherited by a new one.
|
||||
create sequence mesh_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle;
|
||||
|
||||
create table module_identity (
|
||||
module text primary key,
|
||||
uid integer not null unique check (uid between 50000 and 59999),
|
||||
allocated timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table library_group (
|
||||
library text primary key,
|
||||
gid integer not null unique check (gid between 50000 and 59999),
|
||||
allocated timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- **A library taken** (§7). The first time a library's root on its home holds data not yet in its group, the operator
|
||||
-- is asked once for that library; on the warrant the node-engine sets the group, group write and setgid under the root,
|
||||
-- and the library is taken: kept from then on without asking. The row is the record of that act — on which machine
|
||||
-- and filesystem, who approved it, under which warrant. A library whose home or filesystem changed since (it moved to
|
||||
-- another machine or another disk) is not taken by that warrant, and the operator is asked again; a new take replaces
|
||||
-- the row.
|
||||
create table kept_library (
|
||||
library text primary key,
|
||||
home text not null,
|
||||
-- The filesystem the root was on when taken, as the engine names it without a path ("fs-<12 hex>"); empty when
|
||||
-- the engine named none.
|
||||
placed text not null default '',
|
||||
taken_at timestamptz not null default now(),
|
||||
taken_by text not null,
|
||||
ask text not null,
|
||||
warrant text not null,
|
||||
-- What the node-engine answered when it started the take.
|
||||
said text not null default ''
|
||||
);
|
||||
+16
-21
@@ -445,6 +445,16 @@ const RootContract = 3
|
||||
// superseded), is what engines stated before; the controller sends no grant to any engine (#270).
|
||||
const IdentityContract = 5
|
||||
|
||||
// LibraryContract is the statement of an engine that makes the mesh's users and library groups and keeps libraries
|
||||
// (novox/hq ADR 0328, which supersedes ADR 0325): it reads a declaration's `groups`, `identities` and `libraries`,
|
||||
// states a library it may not keep yet as waiting, and hears the take of a library. Only to such an engine is any of
|
||||
// it sent; five, ADR 0325's, is sent nothing new.
|
||||
const LibraryContract = 6
|
||||
|
||||
// KindLibrary is a library's own verdict on its home, as the engine may state it (novox/hq ADR 0328): healthy once
|
||||
// kept. Read beside an access verdict naming the library until the engine's shape is settled.
|
||||
const KindLibrary = "library"
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
@@ -605,19 +615,15 @@ type AccessWait struct {
|
||||
// word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks
|
||||
// again (mesh-host internal/link AccessWait.Placed).
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is what the wait is for: WaitKeep (the operator's word to keep the folder) or WaitDisk (its disk
|
||||
// mounted). ACL is said on a keep wait whose filesystem refuses access entries now: ACLNeedsEnabling when the take
|
||||
// act will let it, ACLUnsupported when no act can. Absent when it allows them.
|
||||
// Reason is what the wait is for: WaitKeep (the operator's word to take the library) or WaitDisk (its disk
|
||||
// mounted).
|
||||
Reason string `json:"reason"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link).
|
||||
// The reasons of an access wait (mesh-host internal/link).
|
||||
const (
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
ACLNeedsEnabling = "needs-enabling"
|
||||
ACLUnsupported = "unsupported"
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
)
|
||||
|
||||
// KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user
|
||||
@@ -630,16 +636,6 @@ const KindAccess = "access"
|
||||
// last named it (`<access id>~entries`), which may no longer be declared. The node-engine's own.
|
||||
const KindKeptEntries = "kept-entries"
|
||||
|
||||
// KeptEntry is one user of a kept folder as the engine's listing says it (ADR 0325 §4), with a note beside the tool
|
||||
// runner's entry.
|
||||
type KeptEntry struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceHealth is one long-running resource's state.
|
||||
type ResourceHealth struct {
|
||||
Module string `json:"module"`
|
||||
@@ -667,10 +663,9 @@ type ResourceHealth struct {
|
||||
// engine older than that.
|
||||
Waits []Wait `json:"waits,omitempty"`
|
||||
// AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7); Act the last walk under its
|
||||
// folder, kept as the engine said it; Entries the kept folder's listing. From an engine at IdentityContract only.
|
||||
// folder, kept as the engine said it. From an engine at IdentityContract or later.
|
||||
AccessWaits []AccessWait `json:"access-waits,omitempty"`
|
||||
Act json.RawMessage `json:"act,omitempty"`
|
||||
Entries []KeptEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
|
||||
|
||||
Vendored
+5
-4
@@ -35,9 +35,10 @@
|
||||
./cmd/mesh-controller TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend
|
||||
./cmd/mesh-controller TestAGateJudgesTheOtherMovesBesideAKeptCore
|
||||
./cmd/mesh-controller TestReplay401
|
||||
# novox/hq ADR 0325 §3, §8, §11 and issue 575: identities allocated once and never root, a folder taken under a
|
||||
# warrant, a placement judged against the build that brings its access.
|
||||
./internal/inventory TestAModuleIsGivenOneIdentityOnceAndNeverRoot
|
||||
./internal/inventory TestAFolderIsTakenOnceUnderAWarrant
|
||||
# novox/hq ADR 0328 §1, §3, §4, §7, §13, §15 and issue 575: numbers allocated once and never root, libraries defined
|
||||
# once and never overlapping, a library taken under a warrant, a placement judged against the build that brings it.
|
||||
./internal/inventory TestOneNumberPerModuleAndLibraryNeverRoot
|
||||
./internal/inventory TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps
|
||||
./internal/inventory TestALibraryIsTakenOnceUnderAWarrant
|
||||
./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess
|
||||
./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead
|
||||
|
||||
Reference in New Issue
Block a user