Compare commits

...
Author SHA1 Message Date
jschoubben 96e15f8693 Hold a joining module at the build it ran, own its directories by its own group, and take a library only when its act is done (hq ADR 0328 review, ADR 0334)
A held module is composed exactly as before it joined, at the build its machine runs, so its port, resources and
contributions stay. A joining module's directories belong to its user and its own group, and its tools' directories
are refused closed to that group; the runner joins it where the engine can say so. A take records the root and is
taken only when the engine states the act done. Libraries' paths and verdicts count modules pulled in by a
requirement, and a unit takes the identity.
2026-10-13 00:03:18 +02:00
jschoubben 7ea6d36eab Pin the node-engine at its ADR 0328 merge (#95), and speak its contract: a library's verdict is said for the modules that use it 2026-10-12 23:02:31 +02:00
jschoubben f634e33792 Libraries and the modules that join them, on the controller's side (hq ADR 0328) 2026-10-12 22:29:35 +02:00
jschoubben fa0a5f1c88 Carry over the ADR 0325 controller work as the base for ADR 0328 2026-10-12 21:57:29 +02:00
40 changed files with 4552 additions and 36 deletions
+23 -2
View File
@@ -117,10 +117,13 @@ type asked struct {
// Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no
// condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions.
Proposal *settingsProposal `json:"proposal,omitempty"`
// Take is a 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.
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil }
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil && r.Take == nil }
// partKey is an ask's place among what is asked: its condition and its part.
func partKey(condition, part string) string { return condition + "#" + part }
@@ -192,6 +195,11 @@ type asker struct {
call func(ctx context.Context, a conditions.Action, args map[string]string) error
// setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot.
setLayer setOnWarrant
// folders is every 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.
record func(ctx context.Context, act link.HandAct) error
// routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing.
@@ -554,6 +562,10 @@ func (a *asker) reconcile(ctx context.Context) error {
return err
}
}
// And every 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 libraries waiting to be taken: %v", err)
}
why := "the router refused the ask"
if len(refusedWords) > 0 {
why += ": " + refusedWords[0]
@@ -818,7 +830,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
if err != nil {
return err
}
stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition
stillOpen := r.Rehearsal || r.Proposal != nil || r.Take != nil // a rehearsal, a proposal and a take are about no condition
for _, c := range open {
stillOpen = stillOpen || c.Key == r.Condition
}
@@ -876,6 +888,15 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") {
outcome = "done: " + outcome
}
case r.Take != nil && act.Verb == takeVerb:
// A 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") {
outcome = "done: " + outcome
}
case r.Take != nil:
acted = fmt.Errorf("the take %s offers no act %s", r.ID, act.Verb)
case act.Arguments["silence"] != "":
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
default:
+21
View File
@@ -572,6 +572,27 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper
call: callAction(conn),
// A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277).
setLayer: setLayerIn(open),
// A shared folder waiting to be kept is asked about once, and taken on the warrant (novox/hq ADR 0325 §8).
folders: foldersIn(open),
take: takeFolderIn(open, func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error) {
ident, err := open.Identity(ctx)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of "+
"%s: %w", act.Node, err)
}
return link.AskTake(ctx, conn, ident, act, link.TakeWithin)
}),
machines: func(ctx context.Context) ([]string, error) {
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return nil, err
}
names := make([]string, 0, len(nodes))
for _, n := range nodes {
names = append(names, n.Name)
}
return names, nil
},
record: func(ctx context.Context, act link.HandAct) error {
_, err := link.RecordHandAct(ctx, conn, act)
return err
+8
View File
@@ -152,6 +152,14 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
}
// **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.LibraryWarnings(shelf[name]) {
fmt.Fprintf(out, "warning: %s\n", w)
}
}
// **A file that asks for a setting and does not say whether it is trusted counts as trusted** (novox/hq issue
// 339): listed and counted, never refused, so an author can opt out a file nothing trusts.
unsaid := 0
+56
View File
@@ -154,6 +154,10 @@ type gateFacts struct {
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
// not (issue 318 review): the only move whose wait for a new login is excused.
groupsAdded map[string]bool
// accessesChanged is, per module, whether the move judged changes its accesses (novox/hq ADR 0325): an access the
// engine cannot judge this look (unknown), or entries it left while a disk was away, of a module whose move leaves
// its accesses as they were is no fault of the move. A module absent here is taken to change them: judged as before.
accessesChanged map[string]bool
// waits is what the controller holds to check a module's wait for the operator (novox/hq ADR 0283): the
// manifest of each module's build judged, and the secrets given on each machine.
waits operatorWaitFacts
@@ -528,6 +532,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
return "", err
}
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
facts.accessesChanged = movesChangingAccesses(ctx, open.inventory, g, pairs, shelf)
facts.waits = gateWaitFacts(ctx, open.inventory, g, pairs, shelf, facts.health)
facts.sent = g.Sent
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
@@ -783,6 +788,57 @@ func joinSaid(a, b string) string {
return a + "; " + b
}
// movesChangingAccesses is, per module a gate judges, whether its move changes its accesses — one added, dropped,
// or given another use — read from the builds' manifests (novox/hq ADR 0325). A move not known changes them: an
// access it cannot judge then holds the gate to its bound, and never passes unseen.
func movesChangingAccesses(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged,
shelf map[string]catalogue.Manifest) map[string]bool {
out := map[string]bool{}
for _, j := range pairs {
if _, done := out[j.module]; done {
continue
}
from, to := g.From, g.To
for _, c := range g.Carried {
if c.Module == j.module {
from, to = c.From, c.To
break
}
}
target, found, err := inv.ManifestAt(ctx, j.module, to)
if err != nil || !found {
target = shelf[j.module]
}
before, had, err := inv.ManifestAt(ctx, j.module, from)
if from == "" || err != nil || !had {
out[j.module] = true
continue
}
out[j.module] = !sameAccessUse(before, target)
}
return out
}
// sameAccessUse says a move leaves a module's accesses 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.LibraryUseShape(a) == catalogue.LibraryUseShape(b)
}
// sameAccesses says two manifests declare the same accesses, at the same use, in any order.
func sameAccesses(a, b []catalogue.Access) bool {
key := func(as []catalogue.Access) string {
var out []string
for _, x := range as {
out = append(out, x.ID+"\x00"+x.Path+"\x00"+x.At())
}
sort.Strings(out)
return strings.Join(out, "\x01")
}
return key(a) == key(b)
}
// movesAddingGroups is, per module a gate judges, whether its move puts an account in a group the build it
// moved from did not: read from the builds' manifests. A module whose move is not known adds none.
func movesAddingGroups(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged,
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"context"
"sort"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// A module that joins keeps the user it ran as until every library it uses is taken (novox/hq ADR 0328 §8).
//
// **Held at the build it ran before, composed exactly as then.** A module whose registered build uses a library not yet
// taken, on a machine that was last sent another build of it, is composed at that build — the manifest the build
// records hold, in place of the registered one, as a recorded module is kept (recorded_kept.go): its resources, its
// ports in the filter, its contributions and bindings are what they were, and nothing of the new contract is sent for
// it. The send records that the machine still carries that build. Once the library is taken, the next composition
// takes the new build. A module the machine never ran has nothing to keep, and is composed as registered; a build the
// records no longer hold cannot be kept, and the module is composed as registered (it then waits as joining modules do).
//
// shelf, when given, has each held module's manifest replaced. It answers what it holds: module → the commit kept.
func holdJoining(ctx context.Context, inv *inventory.Inventory, node string,
shelf map[string]catalogue.Manifest) (map[string]string, error) {
registered := shelf
if registered == nil {
var err error
if registered, err = inv.Catalogue(ctx); err != nil {
return nil, err
}
}
sent, known, err := inv.SentBuilds(ctx, node)
if err != nil || !known {
return nil, err
}
var joining []string
for m := range sent {
if man, has := registered[m]; has && len(man.Libraries) > 0 {
joining = append(joining, m)
}
}
if len(joining) == 0 {
return nil, nil
}
sort.Strings(joining)
libs, err := inv.Libraries(ctx)
if err != nil {
return nil, err
}
taken, err := librariesTaken(ctx, inv, libs)
if err != nil {
return nil, err
}
current, err := inv.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
out := map[string]string{}
for _, m := range joining {
was := sent[m]
if was == "" || sameCommit(was, current[m].Commit) {
continue // the machine already runs the registered build: nothing before it to keep
}
untaken := ""
for _, u := range registered[m].Libraries {
if !taken[u.Library] {
untaken = u.Library
break
}
}
if untaken == "" {
continue
}
before, found, err := inv.ManifestAt(ctx, m, was)
if err != nil {
return nil, err
}
if !found || usesLibrary(before, untaken) {
continue
}
if shelf != nil {
shelf[m] = before
}
out[m] = was
}
return out, nil
}
func usesLibrary(m catalogue.Manifest, library string) bool {
for _, u := range m.Libraries {
if u.Library == library {
return true
}
}
return false
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
const heldImage = "x@sha256:2222222222222222222222222222222222222222222222222222222222222222"
// radarrBefore is radarr as it ran before it joined: its own port, its own data, PUID from a setting.
func radarrBefore() catalogue.Manifest {
return catalogue.Manifest{Module: "radarr", Version: "1", Capabilities: []string{"container-runtime"},
Listens: []catalogue.Listening{{Port: 7878, From: catalogue.FromMesh, Name: "web", Why: "its web UI"}},
Resources: []map[string]any{
{"id": "config", "type": "directory", "mode": "0750"},
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
"env": map[string]any{"PUID": "1000"}, "volumes": []any{"${dir:config}:/config"}},
}}
}
// radarrJoined is the same module's next build, joining the media library.
func radarrJoined() catalogue.Manifest {
m := radarrBefore()
m.Libraries = []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}
m.Resources = []map[string]any{
{"id": "config", "type": "directory", "mode": "0750"},
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
"env": map[string]any{"PUID": "${identity:uid}"},
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}},
}
return m
}
// novox/hq ADR 0328 §8 (the review of 7ea6d36e, item 1): a joining module that ran on a machine before is held until
// its libraries are taken — composed EXACTLY as before it joined, at the build the machine runs: its resources, its
// port in the filter, its contributions; only the new build waits. The record says the machine still carries that build.
func TestAHeldModuleIsComposedAsBeforeItJoined(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
controller := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"}
register(t, open, controller)
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
t.Fatal(err)
}
before, joined := radarrBefore(), radarrJoined()
raw, _ := json.Marshal(before)
b1 := inventory.Build{ID: "b1", Repository: "https://forge.invalid/media.git", Ref: "main", Module: "radarr",
Commit: "c1c1c1c1", On: "builder", Manifest: raw}
if err := inv.RecordBuild(ctx, b1); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: b1.Repository, Ref: "main", BuiltFrom: b1.Commit}); err != nil {
t.Fatal(err)
}
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
t.Fatal(err)
}
gens, err := generators(ctx, open)
if err != nil {
t.Fatal(err)
}
// What anchor composes for radarr before it joined.
plan, settings, err := planFor(ctx, open, "anchor")
if err != nil {
t.Fatal(err)
}
was, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
if err != nil {
t.Fatal(err)
}
wasRules, err := plan.Rules(catalogue.Rendering{Settings: settings})
if err != nil {
t.Fatal(err)
}
record, err := inv.NodeByName(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": b1.Commit}, 1, 1,
inventory.Send{Sequence: 1}); err != nil {
t.Fatal(err)
}
// Anchor's engine keeps libraries; the next build joins media, which is not taken.
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract,
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(joined)
b2 := b1
b2.ID, b2.Commit, b2.Manifest = "b2", "c2c2c2c2", raw
if err := inv.RecordBuild(ctx, b2); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: b2.Repository, Ref: "main", BuiltFrom: b2.Commit}); err != nil {
t.Fatal(err)
}
plan, settings, err = planFor(ctx, open, "anchor")
if err != nil {
t.Fatal(err)
}
held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
if err != nil {
t.Fatal(err)
}
if why, left := held.leftOutWhy["radarr"]; left {
t.Fatalf("radarr was left out while held: %s", why)
}
if !reflect.DeepEqual(radarrResources(was.Resources), radarrResources(held.Resources)) {
t.Fatalf("radarr held is composed otherwise than before it joined:\n %v\n %v", radarrResources(was.Resources),
radarrResources(held.Resources))
}
rules, err := plan.Rules(catalogue.Rendering{Settings: settings})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(rules, wasRules) || !strings.Contains(asJSON(rules), "7878") {
t.Fatalf("radarr's port is not in the filter as before: %s", asJSON(rules))
}
}
func radarrResources(rs []map[string]any) []map[string]any {
var out []map[string]any
for _, r := range rs {
if strings.HasPrefix(r["id"].(string), "radarr.") {
out = append(out, r)
}
}
return out
}
func asJSON(v any) string {
raw, _ := json.Marshal(v)
return string(raw)
}
+401
View File
@@ -0,0 +1,401 @@
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, at its root, on its 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.Root + "\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)
}
// Started, not taken: the library is taken when the engine states the act done (stateHealth, TakeDone).
if err := open.inventory.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node,
Root: t.Root, 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 started taking the library %s: %s; it is taken when the engine says the act is done",
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 withoutTakesUnderWay(takesOf(waiting, f.libraries), f.taken), nil
}
}
// withoutTakesUnderWay is the takes not already under way: a take started on the same home, root and filesystem, which
// the engine has not yet stated done or failed, is not asked about again (the review of 7ea6d36e, item 5).
func withoutTakesUnderWay(takes []libraryTake, recorded map[string]inventory.TakenLibrary) []libraryTake {
var out []libraryTake
for _, t := range takes {
r, was := recorded[t.Library]
if was && !r.Done && r.Home == t.Node && r.Root == t.Root && (t.Placed == "" || r.Placed == "" || r.Placed == t.Placed) {
continue
}
out = append(out, t)
}
return out
}
// 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
}
+545
View File
@@ -0,0 +1,545 @@
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", Root: "/storage/media", Placed: "fs-000000000001", Done: true}
}
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"), ""},
{"taken at another root: its root changed", "ace", keepWait("movies"), func() operatorWaitFacts {
f := libraryFactsWith("media")
f.taken["media"] = inventory.TakenLibrary{Library: "media", Home: "ace", Root: "/storage/old-media", Done: true,
Placed: "fs-000000000001"}
return f
}(), ""},
{"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: 50010}},
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":50010}]`,
`"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")
}
}
// The engine states a library once, of no module; each module assigned to the machine that uses it is judged and said
// by a copy of that verdict — its wait excused and passed by the gate as a wait for a person. A library unknown or
// starting (a take walking under it) of a move that changes no library is not counted.
func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "ace"); err != nil {
t.Fatal(err)
}
for _, m := range []catalogue.Manifest{radarrManifest, plexManifest, {Module: "plain", Version: "1"}} {
if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil {
t.Fatal(err)
}
if m.Module == "plex" {
continue // pulled in by a requirement, not assigned: it is in what the machine was sent (item 4)
}
if _, err := inv.Assign(ctx, "ace", m.Module); err != nil {
t.Fatal(err)
}
}
record, err := inv.NodeByName(ctx, "ace")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "plex": "c2", "plain": "c3"},
1, 1, inventory.Send{Sequence: 1}); err != nil {
t.Fatal(err)
}
lib := inventory.ResourceHealth{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
State: link.StateWaiting, Waits: []inventory.Wait{{Part: "media", Access: "library:media", Reason: link.WaitKeep,
Placed: "fs-000000000001", What: "let the mesh keep the library media"}}}
copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib})
var modules []string
for _, c := range copies {
modules = append(modules, c.Module)
}
if strings.Join(modules, ",") != "plex,radarr" {
t.Fatalf("the library's verdict is said for %v; want plex and radarr, the modules using it", modules)
}
paths, err := inv.LibraryPaths(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Join(paths["media"], ",") != "movies" {
t.Fatalf("the paths kept of media are %v", paths["media"])
}
now := time.Now()
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
accessesChanged: map[string]bool{"radarr": false},
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: copies}}}
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthPerson {
t.Fatalf("radarr waiting on media reads %v %q; want a wait for a person", h, why)
}
for _, state := range []string{link.StateUnknown, link.StateStarting} {
c := copies[1]
c.State, c.Waits = state, nil
f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{c}}
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood {
t.Errorf("media %s, a move that changes no library: %v %q", state, h, why)
}
f.accessesChanged = map[string]bool{"radarr": true}
if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet {
t.Errorf("media %s, a move that changes a library: %v; want held to the bound", state, h)
}
f.accessesChanged = map[string]bool{"radarr": false}
}
}
// The review of 7ea6d36e, item 5: the engine saying it started a take does not make the library taken. While the act
// runs the library still waits and the operator is not asked again; it is taken when the engine states the act done
// under that warrant.
func TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
t.Fatal(err)
}
take := takeFolderIn(open, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
return link.HandOverAnswer{Said: "the take of library:media started"}, nil
})
lt := libraryTake{Library: "media", Node: "anchor", Root: "/storage/media", Placed: "fs-000000000001"}
if _, err := take(ctx, lt, "c01", "the operator"); err != nil {
t.Fatal(err)
}
libs, err := inv.Libraries(ctx)
if err != nil {
t.Fatal(err)
}
taken, err := librariesTaken(ctx, inv, libs)
if err != nil {
t.Fatal(err)
}
if taken["media"] {
t.Fatal("a take only started made the library taken")
}
// While it runs: the wait is still excused, and no new ask is made for it.
var f operatorWaitFacts
readWaitFacts(ctx, inv, "anchor", nil, nil, &f)
if err := checkWait("radarr", "anchor", placedWait("library:media", "fs-000000000001"), f); err != nil {
t.Fatalf("the wait while the take runs: %v", err)
}
if got := withoutTakesUnderWay([]libraryTake{lt}, f.taken); len(got) != 0 {
t.Fatalf("a library whose take runs is asked about again: %+v", got)
}
// The engine states the act done under that warrant.
act, _ := json.Marshal(map[string]any{"act": "take", "access": "library:media", "warrant": "c01", "state": "done"})
if err := stateHealth(ctx, inv, nil, "anchor", link.Health{Contract: link.LibraryContract, At: time.Now(),
Resources: []link.ResourceHealth{{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
State: link.StateHealthy, Since: time.Now(), Act: act}}}, time.Now()); err != nil {
t.Fatal(err)
}
if taken, err = librariesTaken(ctx, inv, libs); err != nil || !taken["media"] {
t.Fatalf("the act done did not make the library taken (%v)", err)
}
}
+129 -2
View File
@@ -2,7 +2,9 @@ package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"sync/atomic"
@@ -40,6 +42,92 @@ const (
moduleUnhealthyAfter = 2
)
// libraryVerdictsFor is, for each library verdict in a statement (kind library, no module), one copy for each module
// on the machine — assigned, or pulled in by a requirement — whose manifest uses that library: what the module is judged and said by. None when the
// assignments or the catalogue cannot be read: the library's own verdict stands alone, and no module is excused by it.
func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node string,
rs []inventory.ResourceHealth) []inventory.ResourceHealth {
var libraries []inventory.ResourceHealth
for _, r := range rs {
if r.Module == "" && r.Kind == link.KindLibrary && strings.HasPrefix(r.Resource, libraryAccess) {
libraries = append(libraries, r)
}
}
if len(libraries) == 0 || inv == nil {
return nil
}
assigned, err := inv.Assigned(ctx, node)
if err != nil {
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
return nil
}
// And the modules a requirement pulled in: what the machine was last sent (the review of 7ea6d36e, item 4).
sent, _, err := inv.SentBuilds(ctx, node)
if err != nil {
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
return nil
}
for m := range sent {
if !slices.Contains(assigned, m) {
assigned = append(assigned, m)
}
}
sort.Strings(assigned)
shelf, err := inv.Catalogue(ctx)
if err != nil {
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
return nil
}
var out []inventory.ResourceHealth
for _, lib := range libraries {
name := strings.TrimPrefix(lib.Resource, libraryAccess)
for _, module := range assigned {
for _, u := range shelf[module].Libraries {
if u.Library == name {
copied := lib
copied.Module = module
out = append(out, copied)
break
}
}
}
}
return out
}
// takesStated keeps what a statement says of each library's take act: done under a warrant, the library is taken;
// failed, the take is forgotten and the operator asked again. Running or stopped (it goes on) changes nothing.
func takesStated(ctx context.Context, inv *inventory.Inventory, rs []link.ResourceHealth) error {
if inv == nil {
return nil
}
for _, r := range rs {
lib, isLibrary := strings.CutPrefix(r.Resource, libraryAccess)
if !isLibrary || r.Kind != link.KindLibrary || len(r.Act) == 0 {
continue
}
var act struct {
Act string `json:"act"`
Warrant string `json:"warrant"`
State string `json:"state"`
}
if err := json.Unmarshal(r.Act, &act); err != nil || act.Act != link.ActTake || act.Warrant == "" {
continue
}
var err error
switch act.State {
case "done":
err = inv.TakeDone(ctx, lib, act.Warrant)
case "failed":
err = inv.TakeFailed(ctx, lib, act.Warrant)
}
if err != nil {
return err
}
}
return nil
}
// healthRefused counts the statements refused as older than the one kept, for the log and a test.
var healthRefused atomic.Int64
@@ -77,8 +165,22 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
for _, w := range r.Waits {
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What})
}
// An access's waits (novox/hq ADR 0325 §7), kept beside the others as one more thing the operator gives: the
// access by its module's id, and whether it waits for a disk rather than for the operator's word.
for _, w := range r.AccessWaits {
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.Reason == link.WaitDisk,
Placed: w.Placed, Reason: w.Reason, What: w.What})
}
resources = append(resources, kept)
}
// **A library's take is done, or failed, when its engine says so** (novox/hq ADR 0328 §7): only then is it taken,
// or asked about again.
if err := takesStated(ctx, inv, h.Resources); err != nil {
return err
}
// **A library's verdict is said for the modules that use it** (novox/hq ADR 0328 §8): the engine states each
// library once, as `library:<name>`, of no module; a module that uses it waits with it, and is said so.
resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...)
// **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose
// wait does not check out is judged unhealthy, saying why; one that does is kept beside the unhealthy ones, so
// judgeModuleHealth can say it as needs-operator. What is stored is what the machine said.
@@ -644,7 +746,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
if waits && !f.groupsAdded[module] {
waits = false
}
var found, onWaiting []string
var found, onWaiting, leftover []string
var forOperator []inventory.Wait
for _, r := range resources {
if r.Module != module {
@@ -667,6 +769,27 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
found = append(found, r.Resource)
continue
}
// **The mesh's entries left on a folder whose disk was away are a condition this build found** when its move
// leaves the module's accesses and their users as they were (novox/hq ADR 0325 §8): they may name an access no
// longer declared, they wait for the disk, and a delivery that did not bring them is not held for them — said in
// the verdict, as a directory used as found is. A move that changes the accesses is held to them, whenever they
// were stated (the review of e1c2b744).
changed, known := f.accessesChanged[module]
changes := !known || changed
if r.Kind == link.KindKeptEntries && r.State == link.StateUnhealthy && !changes {
leftover = append(leftover, r.Resource)
continue
}
// **An access the engine could not judge this look** (its filesystem could not be named — a hung mount) carries
// no wait. Of a module whose move leaves its accesses as they were it is a condition the move found, not
// counted; of one whose move changes them it is not yet healthy, held to the gate's bound like anything still
// unsettled, so the gate fails safe at the bound and never waits for ever.
// The same for a library (ADR 0328): unknown this look, or starting while a take walks under it, of a move that
// leaves the module's libraries as they were, is not this move's.
if (r.Kind == link.KindAccess || r.Kind == link.KindLibrary) && !changes &&
(r.State == link.StateUnknown || (r.Kind == link.KindLibrary && r.State == link.StateStarting)) {
continue
}
switch r.State {
case link.StateHealthy:
case link.StateStarting:
@@ -690,7 +813,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
reasonAfter(r.Reason))
}
}
if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 {
if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 || len(leftover) > 0 {
said := onWaiting
if waits {
said = append(said, wait)
@@ -702,6 +825,10 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+
"(`nox node hand-over %s <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
}
if len(leftover) > 0 {
said = append(said, fmt.Sprintf("on %s, the mesh's entries %s could not be taken out while their disk was "+
"away, stated before this send: they are taken out when it is back", machine, strings.Join(leftover, ", ")))
}
return healthPerson, strings.Join(said, "; ")
}
return healthGood, ""
+102 -2
View File
@@ -44,17 +44,34 @@ func needsOperatorKey(module, node string) string {
type operatorWaitFacts struct {
manifests map[string]catalogue.Manifest
given map[string]map[string]time.Time
// 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)
}
named := 0
for _, n := range []string{w.Secret, w.Setting, w.Access} {
if n != "" {
named++
}
}
switch {
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "":
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w))
case named != 1:
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting, or one access", waitNames(w))
case w.Access != "":
return checkAccessWait(module, machine, m, w, f)
case w.Setting != "":
if _, declared := m.Settings[w.Setting]; !declared {
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
@@ -80,6 +97,63 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
return nil
}
// 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 {
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)
}
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:
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 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 = ""
}
if t, was := f.taken[lib]; was && t.Done && t.Home == l.Home && t.Root == l.Root &&
(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"
@@ -88,6 +162,10 @@ func waitNames(w inventory.Wait) string {
switch {
case w.Secret != "" && w.Setting != "":
return "the secret " + w.Secret + " and the setting " + w.Setting
case w.Access != "" && (w.Secret != "" || w.Setting != ""):
return "the access " + w.Access + " and a secret or a setting"
case w.Access != "":
return "the access " + w.Access
case w.Secret != "":
return "the secret " + w.Secret
case w.Setting != "":
@@ -147,6 +225,14 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
var parts []string
for _, w := range waits {
part := fmt.Sprintf("%s (%s", w.What, waitNames(w))
// An access in the controller's own words, never the engine's: words with no path (novox/hq ADR 0325 §7).
switch {
case w.Access != "" && w.Disk:
part = fmt.Sprintf("the disk that holds %s mounted (%s", accessWords(w.Access), waitNames(w))
case 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 != "" {
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
}
@@ -180,6 +266,11 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
case w.Setting != "":
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
// An access, in words with no path (novox/hq ADR 0325 §7): the folder by the access's name.
case w.Access != "" && w.Disk:
act = fmt.Sprintf("mount the disk that holds %s on %s", accessWords(w.Access), node)
case w.Access != "":
act = fmt.Sprintf("let the mesh keep %s when you are asked", accessWords(w.Access))
}
if act != "" && !seen[act] {
seen[act] = true
@@ -217,6 +308,7 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
if f.given == nil {
f.given = map[string]map[string]time.Time{}
}
if inv == nil {
return
}
@@ -245,6 +337,14 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
f.given[module+"@"+machine] = given
}
}
// 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
}
}
}
// waitingModules is every module with a waiting resource in a statement.
+158 -4
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"slices"
"sort"
"strings"
@@ -133,6 +134,10 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil {
return catalogue.Resolution{}, nil, err
}
// And a module that joins a library not yet taken, at the build it ran before (novox/hq ADR 0328 §8).
if _, err := holdJoining(ctx, inv, nodeName, shelf); err != nil {
return catalogue.Resolution{}, nil, err
}
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
@@ -447,7 +452,8 @@ func declarationWith(ctx context.Context, open *stores, node string,
if err != nil {
return sendable{}, err
}
out := sendable{Resources: composed.Resources, Adoption: adoption,
out := sendable{Resources: composed.Resources, Adoption: adoption, 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,
@@ -471,11 +477,22 @@ func declarationWith(ctx context.Context, open *stores, node string,
names = append(names, m.Module)
}
out.Builds = carriedBuilds(names, composed.LeftOut, current, before)
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245).
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245), and so
// is a joining module held at the build it ran before (ADR 0328 §8).
kept, err := recordedKept(ctx, open, node)
if err != nil {
return sendable{}, err
}
held, err := holdJoining(ctx, open.inventory, node, nil)
if err != nil {
return sendable{}, err
}
if kept == nil && len(held) > 0 {
kept = map[string]string{}
}
for m, was := range held {
kept[m] = was
}
for m, was := range kept {
if _, carried := out.Builds[m]; carried {
out.Builds[m] = was
@@ -975,9 +992,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
// 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,
ReadsHealth: readsHealth,
JudgesRoot: judgesRoot,
KeepsLibraries: lf.keeps, Identities: lf.identities, Libraries: lf.libraries, LibraryGroups: lf.groups,
TakenLibraries: lf.taken, LibraryPaths: lf.paths, RunnerJoinsOwnGroups: lf.ownGroups,
BusMembership: memberships[node],
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
@@ -1010,6 +1035,135 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
return had && stated.Contract >= link.RootContract, nil
}
// 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.LibraryContract, nil
}
// 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
ownGroups bool
}
// 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, and the sub-paths every module asks of each. **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 {
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
}
if stated, had, err := inv.HealthOf(ctx, node); err != nil {
return lf, err
} else {
lf.ownGroups = had && stated.Contract >= link.OwnGroupContract
}
lf.identities = map[string]catalogue.ModuleIdentity{}
for _, m := range plan.Modules {
if !catalogue.Joins(m) {
continue
}
var id catalogue.ModuleIdentity
had := true
if choosing == Allocating {
id, err = inv.AllocateIdentity(ctx, m.Module)
} else {
id, had, err = inv.IdentityOf(ctx, m.Module)
}
if err != nil {
return lf, err
}
if had {
lf.identities[m.Module] = id
}
}
if lf.groups, err = inv.LibraryGroups(ctx); err != nil {
return lf, err
}
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
}
}
}
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
}
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.Done && t.Home == l.Home && t.Root == l.Root
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 out, nil
}
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
// 0199): the zone settled from that node's settings, the node's private address, the port the
// answering listen is published on there.
+15
View File
@@ -18,6 +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
// 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.
@@ -110,6 +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".
+1 -1
View File
@@ -36,4 +36,4 @@ require (
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
// `go mod vendor` fails loudly, at once, everywhere.
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU=
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b h1:NvXKvFi4lV/oXg29dQ9iUX5oQm+NtPN9ACTa9Ev1i8E=
git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+8 -1
View File
@@ -343,6 +343,11 @@ func AskJudgeDirectoriesSubject(node string) string {
return "mesh.node." + node + ".ask.judge-directories"
}
// AskAccessActSubject is where the controller asks one machine's node-engine to take a shared folder on the
// operator's warrant (novox/hq ADR 0325 §8; mesh-host internal/link AskAccessActSubject): a request answered once,
// signed as a hand-over is (link.AccessActContext), for the same reason.
func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" }
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
@@ -623,7 +628,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
AskSetuidSearchSubject(p.Node),
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
AskJudgeDirectoriesSubject(p.Node)}
AskJudgeDirectoriesSubject(p.Node),
// And asking it to take a shared folder on the operator's warrant (novox/hq ADR 0325 §8), answered the same way.
AskAccessActSubject(p.Node)}
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
// machine runs the controller, reads the lease's one key — read, never written.
+1 -1
View File
@@ -34,7 +34,7 @@ accounts {
} }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
+5
View File
@@ -101,6 +101,11 @@ var WritersTable = []WriterRow{
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
// The take of a shared folder asked of a machine's engine on the operator's warrant (novox/hq ADR 0325 §8):
// signed as a hand-over is, under a signing context of its own.
{State: "the take of a shared folder asked of a machine", Writer: "controller, on the operator's warrant",
KeptIn: "the machine, which records the act", Others: "the engine verifies the mesh's signature and takes it, or refuses",
Subjects: []string{"mesh.node.*.ask.access-act"}, Writes: isController},
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
+1
View File
@@ -18,6 +18,7 @@ var designRows = []string{
"a hand-over asked of a machine",
"a fresh setuid search asked of a machine",
"a judgement of a machine's directories asked of it",
"the take of a shared folder asked of a machine",
"a machine's applied state and its report",
"the controller lease",
"plans and their tiers",
+4 -2
View File
@@ -939,14 +939,16 @@ func replaceGoFiles(from, into string) error {
}
// problemLines are the lines of a module check's output that name a problem: not a manifest found ok, not
// the count, not the closing note.
// the count, not the closing note, not a warning.
func problemLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
switch {
case line == "", strings.Contains(line, ": ok"), strings.Contains(line, "problem(s) in"),
strings.Contains(line, "manifest(s) checked"):
strings.Contains(line, "manifest(s) checked"),
// A warning is said and fails nothing (novox/hq ADR 0325: what is refused once the catalogues moved).
strings.HasPrefix(line, "warning: "):
continue
}
out = append(out, line)
+68 -5
View File
@@ -246,6 +246,25 @@ type Rendering struct {
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
// sent, and the account it names is not judged — which the self-check says, as not judged.
JudgesRoot bool
// 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
// 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
// RunnerJoinsOwnGroups says this machine's engine puts the tool runner's account in a joining module's own group
// (novox/hq ADR 0334 §2: its statement's contract is link.OwnGroupContract or later); to an older one the
// identity's runner is not sent.
RunnerJoinsOwnGroups bool
}
// RootField is a user resource's field saying the account must never become root without a person
@@ -320,6 +339,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
// 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
@@ -358,7 +382,9 @@ func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string
out[m.Module] = &UnreadManifestError{Module: m.Module, said: why}
continue
}
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
// A placement stored ahead of the build that declares its access is passed by, not a reason to leave the
// module out (novox/hq issue 575): it was judged against that build where it was stored.
if err := ComposesWith(m, settings[m.Module], adopted); err != nil {
out[m.Module] = err
}
}
@@ -380,7 +406,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
received := map[string]map[string][]Contribution{}
leftOut := map[string]string{}
var foreseen, unplaced []string
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced)
var joined []Joined
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined)
if err != nil {
return Composed{}, err
}
@@ -394,8 +421,10 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
})
}
sort.Strings(unplaced)
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
Foreseen: foreseen, Unplaced: unplaced}, nil
composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
Foreseen: foreseen, Unplaced: unplaced}
composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined)
return composed, nil
}
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
@@ -406,13 +435,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) ([]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 {
@@ -553,6 +595,20 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
}
resources := m.Resources
// 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)
}
}
// What the mesh computes for this module goes FIRST, before the module's own resources.
//
// **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so
@@ -1003,6 +1059,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
return nil, err
}
ownerInto(copied, placed)
// 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
// has been put in — filling secrets first would look at content that is not yet what
+899
View File
@@ -0,0 +1,899 @@
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
}
problems = append(problems, toolsDirectoryProblems(m)...)
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
}
// toolsDirectoryProblems refuses a joining module whose tools name one of its directories whose mode closes it to its
// group (novox/hq ADR 0334 §3): the tool runner reads it through the module's own group, so it needs group read and
// enter.
func toolsDirectoryProblems(m Manifest) []string {
modes := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
mode, _ := r["mode"].(string)
modes[fmt.Sprint(r["id"])] = mode
}
}
var words []string
for _, b := range m.Bundles {
for _, v := range b.Env {
words = append(words, v)
}
}
if m.Build != nil {
for _, a := range m.Build.Artifacts {
for _, v := range a.Env {
words = append(words, v)
}
}
}
seen := map[string]bool{}
var problems []string
for _, w := range words {
for _, match := range dirRef.FindAllStringSubmatch(w, -1) {
id := match[1]
mode, declared := modes[id]
if !declared || seen[id] {
continue
}
seen[id] = true
var n uint64
if _, err := fmt.Sscanf(mode, "%o", &n); err != nil || mode == "" {
continue // its mode is judged where the directory is
}
if n&0o050 != 0o050 {
problems = append(problems, fmt.Sprintf("%s's tools name its directory %s, whose mode %s closes it to its "+
"own group: the tool runner reads it through that group, so it needs group read and enter (0750) "+
"(novox/hq ADR 0334 §3)", m.Module, id, mode))
}
}
}
sort.Strings(problems)
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
AddedGIDs []int
}
// 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))
j.AddedGIDs = append(j.AddedGIDs, gid)
}
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), or an engine that cannot be told it joins. A module held at the
// user it ran as until its libraries are taken (§8) is not left out: it is composed at the build it ran before it
// joined (cmd/mesh-controller holdJoining).
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)
}
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
}
// Its own directories belong to its user and its own group (ADR 0334 §1): the library's group is its processes'
// primary, so what they make in a library keeps that group, and its own data stays its own.
if fmt.Sprint(resource["type"]) == "directory" {
resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.UID)
}
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; mesh-host internal/declaration
// Identity): the engine always makes its own group with its uid; GID is its primary group's number — its first
// library's, or its own — and Groups the numbers of its added groups.
type IdentityEntry struct {
Module string `json:"module"`
Name string `json:"name"`
UID int `json:"uid"`
GID int `json:"gid"`
Groups []int `json:"groups,omitempty"`
// Runner is the tool runner's account, put in this module's own group, where its tools run there (ADR 0334 §2).
// Only to an engine that reads it (Rendering.RunnerJoinsOwnGroups).
Runner string `json:"runner,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.PrimaryGID,
Groups: append([]int(nil), j.AddedGIDs...)}
if with.RunnerJoinsOwnGroups {
for _, m := range r.Modules {
if m.Module == j.Module {
e.Runner = r.toolsAccount(m, with)
}
}
}
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 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
}
+419
View File
@@ -0,0 +1,419 @@
package catalogue
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-host/validate"
)
// 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:50020" {
t.Errorf("its directory is owned by %v; want its user and own group (ADR 0334 §1)", owner)
}
if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr",
Name: "mesh-radarr", UID: 50020, GID: 50010, Groups: []int{50011}}) {
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 14: a module that neither declares libraries nor uses its identity is composed exactly as before — the whole
// declaration: its resources, the filter its port reaches, the contributions it makes — on an engine that keeps
// libraries or not; on a machine a library reaches, only that library's group is added. A joining module on an older
// engine is left out, naming why.
func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) {
plain := aPlain()
plain.Listens = []Listening{{Port: 8080, From: FromEverywhere, Name: "web", Why: "its page"}}
plain.Contributes = map[string]map[string]any{"reverse-proxy": {"host": "plain", "port": 8080}}
plain.Requires = []string{"reverse-proxy"}
modules := []Manifest{plain, proxy(), {Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
theRuntime(t)}
base := Rendering{Mesh: []string{"198.51.100.2"}, OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"}
keeping := func(node string) Rendering {
with := librariesRendering()
with.Mesh, with.OutwardLinks, with.TunnelInterface = base.Mesh, base.OutwardLinks, base.TunnelInterface
return with
}
for _, node := range []string{"far", "ace"} {
r := Resolution{Node: node, Account: "jochen", Modules: modules}
before := composeLibraries(t, r, base)
after := composeLibraries(t, r, keeping(node))
if node == "far" {
if !reflect.DeepEqual(before, after) {
t.Fatalf("on a machine no library reaches, a module that does not join composed otherwise:\n %+v\n %+v",
before, after)
}
continue
}
groups := after.Groups
after.Groups, after.Libraries = nil, nil
if !reflect.DeepEqual(before, after) {
t.Fatalf("on %s, a module that does not join composed otherwise:\n %+v\n %+v", node, before, after)
}
if len(groups) == 0 || len(after.Identities) > 0 {
t.Fatalf("on the library's home: groups %v, identities %v", groups, after.Identities)
}
filter := fmt.Sprint(byID(before.Resources)["nftables."+FilteringID()]["content"])
if !strings.Contains(filter, "8080") || received(t, before.Resources)[0].From != "plain" {
t.Fatalf("the comparison held no filter or contribution of the module: %s", filter)
}
}
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")
}
}
// What the node-engine this controller pins (mesh-host #95, ADR 0328's half) makes of a composed declaration with
// libraries and a joining module: nothing refused.
func TestTheEngineTakesWhatIsComposedForLibraries(t *testing.T) {
for _, node := range []string{"ace", "novox"} {
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
with := librariesRendering()
with.Libraries["books"] = Library{Name: "books", Home: "ace", Root: "/storage/books", Reach: []string{"novox"}}
composed := composeLibraries(t, r, with)
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
"groups": composed.Groups, "identities": composed.Identities, "libraries": composed.Libraries})
if err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(body); len(problems) > 0 {
t.Fatalf("%s: the pinned node-engine refuses it: %v", node, problems)
}
}
}
// novox/hq ADR 0334 §1, §2 (the review of 7ea6d36e, item 2): a joining module's own directories are owned by its user
// and its own group, not the library's; and the tool runner's account joins the own group of every joining module whose
// tools run on the machine — sent only to an engine that reads it (link.OwnGroupContract).
func TestAJoiningModulesOwnDirectoriesAreItsOwnGroups(t *testing.T) {
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
got := byID(composeLibraries(t, r, librariesRendering()).Resources)
if owner := got["radarr.config"]["owner"]; owner != "50020:50020" {
t.Errorf("its directory is owned by %v; want its user and its own group", owner)
}
if env := got["radarr.app"]["env"].(map[string]any); env["PGID"] != "50010" {
t.Errorf("its process's primary group is %v; want the library's, so what it makes there keeps the group", env["PGID"])
}
older := composeLibraries(t, r, librariesRendering())
if older.Identities[0].Runner != "" {
t.Errorf("an engine that cannot put the runner in a module's own group was told %q", older.Identities[0].Runner)
}
with := librariesRendering()
with.RunnerJoinsOwnGroups = true
if got := composeLibraries(t, r, with).Identities[0].Runner; got != "jochen" {
t.Errorf("the runner joins %q's own group; want jochen, whose tools it runs", got)
}
}
// novox/hq ADR 0334 §3: a joining module whose tools name a directory whose mode closes it to its group is refused at
// the module check, naming the directory.
func TestAToolsDirectoryIsOpenToItsGroup(t *testing.T) {
closed := map[string]any{
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read"}},
"resources": []any{
map[string]any{"id": "config", "type": "directory", "mode": "0700"},
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}},
"build": map[string]any{"artifacts": []any{map[string]any{"name": "tools", "kind": "bundle",
"language": "go", "system": "arch", "from": "cmd/app", "binary": "app", "loads": []any{"app"},
"env": map[string]any{"APP_CONFIG": "${dir:config}/config.xml"}}}}}
_, err := ParseManifest(libraryManifest(t, closed))
if err == nil || !strings.Contains(err.Error(), "config") || !strings.Contains(err.Error(), "ADR 0334") {
t.Fatalf("a tools' directory closed to its group: %v", err)
}
open := closed
open["resources"] = []any{
map[string]any{"id": "config", "type": "directory", "mode": "0750"},
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}}
if _, err := ParseManifest(libraryManifest(t, open)); err != nil {
t.Fatalf("a tools' directory open to its group was refused: %v", err)
}
}
// The review of 7ea6d36e, item 6: a service's unit takes the identity too — `User=${identity:user}` — at the module
// check and in what is composed.
func TestAServicesUnitTakesTheIdentity(t *testing.T) {
unit := "[Service]\nUser=${identity:user}\nGroup=${identity:group}\nUMask=0002\nExecStart=/usr/bin/importer\n"
raw := libraryManifest(t, map[string]any{"resources": []any{
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
"volumes": []any{"${library:movies}:/movies"}},
map[string]any{"id": "watch", "type": "service", "name": "app-watch", "unit": unit}}})
m, err := ParseManifest(raw)
if err != nil {
t.Fatalf("a unit naming the identity was refused: %v", err)
}
with := librariesRendering()
with.Identities["app"] = ModuleIdentity{Module: "app", UID: 50030}
composed := composeLibraries(t, Resolution{Node: "ace", Modules: []Manifest{m}}, with)
got := fmt.Sprint(byID(composed.Resources)["app.watch"]["unit"])
if !strings.Contains(got, "User=mesh-app\nGroup=mesh-media\n") {
t.Fatalf("the unit reads %q", got)
}
}
+5
View File
@@ -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,6 +1863,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, replacesProblems(m)...)
problems = append(problems, UnitSettingProblems(m)...)
problems = append(problems, OwnerSettingProblems(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 {
+32 -3
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"errors"
"fmt"
"path/filepath"
"regexp"
@@ -270,6 +271,31 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
// It refuses an id the module declares no access under, and a path that is not absolute. An
// access declared by path alone cannot be placed — it has no name to place it by.
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
return accessPlaces(m, layers, true)
}
// UndeclaredAccessError is a layer placing an access the definition does not declare (novox/hq issue 575): refused
// where a setting is stored, unless a newer build of the module declares it; passed by where a machine is composed.
type UndeclaredAccessError struct {
Module, ID string
said string
}
func (e *UndeclaredAccessError) Error() string { return e.said }
// UndeclaredAccessPlaces refuses a layer that places an access the definition does not declare.
func UndeclaredAccessPlaces(m Manifest, layers []Layer) error {
_, err := accessPlaces(m, layers, true)
var undeclared *UndeclaredAccessError
if errors.As(err, &undeclared) {
return err
}
return nil
}
// accessPlaces is AccessPlaces; where strict is false an access the definition does not declare is passed by
// (novox/hq issue 575), as a placement stored ahead of the build that declares it.
func accessPlaces(m Manifest, layers []Layer, strict bool) (map[string]string, error) {
declared := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
@@ -289,9 +315,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
}
for id, body := range blocks {
if !declared[id] {
return nil, fmt.Errorf(
if !strict {
continue
}
return nil, &UndeclaredAccessError{Module: m.Module, ID: id, said: fmt.Sprintf(
"%s places the access %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))}
}
path, _ := body.(string)
path = strings.TrimSpace(path)
@@ -324,7 +353,7 @@ type placedAccess struct {
// placed one, the definition's where it carries a default, and refused where neither says — an
// access that resolves to nowhere would reach the machine as a mount of nothing.
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
placed, err := AccessPlaces(m, layers)
placed, err := accessPlaces(m, layers, false)
if err != nil {
return nil, nil, err
}
+4
View File
@@ -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{}
}
+32
View File
@@ -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 {
@@ -509,6 +513,34 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
if err := UndeclaredAccessPlaces(m, layers); err != nil {
return err
}
return ComposesWith(m, layers, adopted)
}
// ComposesWith is JudgeSettings for a layer stored ahead of the definition that reads it (novox/hq issue 575): an
// access placed that this definition does not declare yet is not refused, because composition passes it by — it
// reaches nothing until the build that declares it registers, and then it places that access. Where a machine is
// declared (LeftOutBecause), and where a setting judged against a newer build of the module is held to still compose
// with the one registered.
func ComposesWith(m Manifest, layers []Layer, adopted bool) error {
// 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
}
+4
View File
@@ -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", "unit"}}},
{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)"},
+74 -1
View File
@@ -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)
@@ -958,7 +966,38 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
}
layers = append(layers, catalogue.Layer{From: from, Values: values})
if err := catalogue.JudgeSettings(m, layers, adopted); err != nil {
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
// **A placement for an access the module's newer build brings** (novox/hq issue 575): judged against the
// manifest of the module's newest build on the branch it follows, where the registered one does not declare
// the access yet — so an access can be placed before or with the delivery that adds it. It must still compose
// with the registered manifest, which passes the placement by until the build registers.
var undeclared *catalogue.UndeclaredAccessError
if !errors.As(err, &undeclared) {
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
}
ahead, has, aerr := i.NewestManifestOnBranch(ctx, module)
if aerr != nil {
return aerr
}
if !has {
follows, ferr := i.followsABranch(ctx, module)
if ferr != nil {
return ferr
}
if !follows {
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; and %s follows no branch "+
"(it was registered by hand), so no build of it can be judged ahead of what is registered", module,
where, from, err, module)
}
}
if !has || catalogue.JudgeSettings(ahead, layers, adopted) != nil || catalogue.ComposesWith(m, layers, adopted) != nil {
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; nor does the newest build of "+
"%s on the branch it follows declare it", module, where, from, err, module)
}
m = ahead
}
// A module that 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:
// stored, it would be a setting somebody believes they made.
@@ -969,6 +1008,40 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
return nil
}
// followsABranch says a module's record names the branch it follows.
func (i *Inventory) followsABranch(ctx context.Context, module string) (bool, error) {
var ref string
err := i.store.Pool().QueryRow(ctx, `select coalesce(ref, '') from module where name = $1`, module).Scan(&ref)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return ref != "", err
}
// NewestManifestOnBranch is the manifest of a module's newest successful build on the branch its record follows
// (novox/hq issue 575): what the delivery under way brings, before it registers — or the registered one's own, when
// nothing newer was built. Only a build of that very branch: never one of another branch or of none. False when no
// such build is kept, and for a module whose record follows no branch (registered by hand).
func (i *Inventory) NewestManifestOnBranch(ctx context.Context, module string) (catalogue.Manifest, bool, error) {
var raw []byte
err := i.store.Pool().QueryRow(ctx,
`select b.manifest from build b join module m on m.name = b.module
where b.module = $1 and b.failed = '' and b.manifest is not null and b.manifest::text <> 'null'
and coalesce(m.ref, '') <> '' and b.ref = m.ref
order by b.at desc limit 1`, module).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return catalogue.Manifest{}, false, nil
}
if err != nil {
return catalogue.Manifest{}, false, err
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
return catalogue.Manifest{}, false, nil // a build the mesh cannot read judges nothing
}
return m, true, nil
}
// givenIn is the machine ports a node-level settings layer gives a module, software port →
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
// for composition to refuse in its own words.
+12 -3
View File
@@ -38,13 +38,22 @@ type ResourceHealth struct {
Waits []Wait `json:"waits,omitempty"`
}
// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret and Setting
// (novox/hq ADR 0283), as link.Wait carries it.
// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret, Setting and Access
// (novox/hq ADR 0283, ADR 0325 §7), as link.Wait carries it.
type Wait struct {
Part string `json:"part"`
Secret string `json:"secret,omitempty"`
Setting string `json:"setting,omitempty"`
What string `json:"what"`
// Access is an access of the module by its own id for it, from the engine's access-waits, and Disk that it
// waits for the disk that holds its folder rather than for the operator's word (novox/hq ADR 0325 §7, §9).
Access string `json:"access,omitempty"`
Disk bool `json:"disk,omitempty"`
// Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not
// answer the wait.
Placed string `json:"placed,omitempty"`
// Reason is the engine's reason for an access wait: "keep" or "disk".
Reason string `json:"reason,omitempty"`
What string `json:"what"`
}
// NodeHealth is a machine's newest statement, as kept.
+292
View File
@@ -0,0 +1,292 @@
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 on any machine asks of each library (§6): what its home keeps. A module on a
// machine is one assigned there or one a requirement pulled in — what the machine was last sent.
func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, error) {
shelf, err := i.Catalogue(ctx)
if err != nil {
return nil, err
}
// Every module assigned anywhere, and every module a machine was last sent — those a requirement pulled in too.
rows, err := i.store.Pool().Query(ctx,
`select module from assignment
union select jsonb_object_keys(sent_builds) from node where sent_builds is not null and jsonb_typeof(sent_builds) = 'object'`)
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
Root string
Placed string
By string
Ask string
Warrant string
Said string
// Done is whether the engine stated the take's act done: only then is the library taken.
Done bool
}
// TakenLibraries is every library whose take was started, by name; Done says it is taken.
func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary, error) {
rows, err := i.store.Pool().Query(ctx,
`select library, home, root, placed, taken_by, ask, warrant, said, done 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.Root, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Done); err != nil {
return nil, err
}
out[t.Library] = t
}
return out, rows.Err()
}
// RecordTakeStarted keeps that a take of a library was started on its home, under the operator's warrant: the
// engine said it began. The library is taken only once the engine states the act done (TakeDone). Once per home, root
// and filesystem: a second start of the same keeps the first, and one on another (it moved, and the operator was asked
// again) replaces it, not done.
func (i *Inventory) RecordTakeStarted(ctx context.Context, t TakenLibrary) error {
switch {
case t.Library == "" || t.Home == "" || !strings.HasPrefix(t.Root, "/"):
return errors.New("a taken library names itself, its home and its root")
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, root, placed, taken_by, ask, warrant, said, done)
values ($1, $2, $3, $4, $5, $6, $7, $8, false)
on conflict (library) do update
set home = excluded.home, root = excluded.root, placed = excluded.placed, taken_at = now(),
taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant, said = excluded.said,
done = false
where kept_library.home <> excluded.home or kept_library.root <> excluded.root
or kept_library.placed <> excluded.placed`,
t.Library, t.Home, t.Root, t.Placed, t.By, t.Ask, t.Warrant, t.Said)
return err
}
// TakeDone keeps that the engine stated a library's take done under a warrant: the library is taken. A warrant the
// recorded take was not started under changes nothing.
func (i *Inventory) TakeDone(ctx context.Context, library, warrant string) error {
_, err := i.store.Pool().Exec(ctx,
`update kept_library set done = true where library = $1 and warrant = $2 and not done`, library, warrant)
return err
}
// TakeFailed forgets a take the engine stated failed under a warrant, never one done: the library is asked about again.
func (i *Inventory) TakeFailed(ctx context.Context, library, warrant string) error {
_, err := i.store.Pool().Exec(ctx,
`delete from kept_library where library = $1 and warrant = $2 and not done`, library, warrant)
return err
}
+393
View File
@@ -0,0 +1,393 @@
package inventory
import (
"encoding/json"
"fmt"
"sort"
"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.RecordTakeStarted(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", Root: "/storage/media", Placed: "fs-000000000001",
By: "the operator", Ask: "c01", Warrant: "c01"}
if err := inv.RecordTakeStarted(ctx, first); err != nil {
t.Fatal(err)
}
again := first
again.Ask, again.Warrant, again.By = "c02", "c02", "someone else"
if err := inv.RecordTakeStarted(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.RecordTakeStarted(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)
}
// The review of 7ea6d36e, item 3: the root is part of what was taken — a library whose root changed is taken
// again, and the new take replaces the old.
if got := taken["media"].Root; got != "/storage/media" {
t.Fatalf("the take recorded the root %q", got)
}
rooted := moved
rooted.Root, rooted.Ask, rooted.Warrant = "/storage/media2", "c04", "c04"
if err := inv.RecordTakeStarted(ctx, rooted); err != nil {
t.Fatal(err)
}
if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c04" || taken["media"].Root != "/storage/media2" {
t.Fatalf("a take at another root: %+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)
}
}
// The review of 7ea6d36e, item 4: every sub-path a module asks of a library is kept on its home, whether the module
// was assigned or pulled in by a requirement (it is in what its machine was last sent).
func TestTheSubPathsOfModulesPulledInByARequirementAreKept(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
record, err := inv.AddNode(ctx, "ace")
if err != nil {
t.Fatal(err)
}
for _, m := range []catalogue.Manifest{
{Module: "radarr", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "m", Library: "media", Path: "movies", Use: "read-write"}}},
{Module: "importer", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "i", Library: "media", Path: "incoming", Use: "read-write"}}},
} {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
}
if _, err := inv.Assign(ctx, "ace", "radarr"); err != nil {
t.Fatal(err)
}
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "importer": "c2"}, 1, 1,
Send{Sequence: 1}); err != nil {
t.Fatal(err)
}
paths, err := inv.LibraryPaths(ctx)
if err != nil {
t.Fatal(err)
}
got := append([]string{}, paths["media"]...)
sort.Strings(got)
if strings.Join(got, ",") != "incoming,movies" {
t.Fatalf("the paths kept of media are %v; want the importer's, pulled in by a requirement, too", got)
}
}
// The review of 7ea6d36e, item 5: a take the engine says it started is recorded as started, not taken; it is taken when
// the engine states the act done under that warrant, and forgotten when it states it failed, so the operator is asked
// again.
func TestALibraryIsTakenWhenTheActIsDone(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
started := TakenLibrary{Library: "media", Home: "ace", Root: "/storage/media", Placed: "fs-000000000001",
By: "the operator", Ask: "c01", Warrant: "c01"}
if err := inv.RecordTakeStarted(ctx, started); err != nil {
t.Fatal(err)
}
taken, err := inv.TakenLibraries(ctx)
if err != nil {
t.Fatal(err)
}
if got := taken["media"]; got.Done || got.Ask != "c01" {
t.Fatalf("a take only started reads %+v; want started, not done", got)
}
if err := inv.TakeDone(ctx, "media", "c99"); err != nil {
t.Fatal(err)
}
if taken, _ = inv.TakenLibraries(ctx); taken["media"].Done {
t.Fatal("another warrant's act made the take done")
}
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
t.Fatal(err)
}
if taken, _ = inv.TakenLibraries(ctx); !taken["media"].Done {
t.Fatal("the act done under the take's warrant did not make it taken")
}
// A failed act forgets a take not done, so it is asked again; never one done.
again := started
again.Root, again.Ask, again.Warrant = "/storage/media2", "c02", "c02"
if err := inv.RecordTakeStarted(ctx, again); err != nil {
t.Fatal(err)
}
if err := inv.TakeFailed(ctx, "media", "c02"); err != nil {
t.Fatal(err)
}
if taken, _ = inv.TakenLibraries(ctx); taken["media"].Ask == "c02" {
t.Fatalf("a failed take is still recorded: %+v", taken["media"])
}
}
@@ -0,0 +1,50 @@
-- 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
-- root and filesystem, who approved it, under which warrant. A library whose home, root or filesystem changed since
-- (it moved to another machine, path or 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 root it was taken at: a library whose root changed since is taken again.
root text not null check (root like '/%'),
-- 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 '',
-- Whether the engine has stated the take's act done under this warrant: only then is the library taken. Until
-- then the take is under way — the library still waits, and the operator is not asked again — and an act stated
-- failed forgets the row, so the operator is asked again.
done boolean not null default false
);
+65
View File
@@ -8,6 +8,7 @@ package link
import (
"encoding/base64"
"encoding/json"
"github.com/novox/mesh-host/rootsearch"
"strconv"
"strings"
@@ -436,6 +437,29 @@ const ReadinessContract = 2
// parses strictly and would refuse the whole declaration for it.
const RootContract = 3
// IdentityContract is the statement of an engine that gives each module its identity and keeps shared folders
// (novox/hq ADR 0325; mesh-host internal/link IdentityContract): it reads a declaration's `identities`, a container's
// resolved `identity`, an access's `users` and `taken`, states an access it may not keep yet as waiting
// (ResourceHealth.AccessWaits), and hears the take act (AskAccessAct). Only to such an engine is any of it sent: an
// older one parses strictly and would refuse the whole declaration for it. Four, the grant's number (ADR 0312,
// superseded), is what engines stated before; the controller sends no grant to any engine (#270).
const IdentityContract = 5
// 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
// OwnGroupContract is the statement of an engine that also puts the tool runner's account in a joining module's own
// group, named by an identity's `runner` (novox/hq ADR 0334 §2). Not stated by any engine yet: the engine must add
// `runner` (an account's name) to its declaration's Identity, and put that account in the group mesh-<module>.
const OwnGroupContract = 7
// 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"
@@ -580,6 +604,43 @@ type Wait struct {
What string `json:"what"`
}
// WaitKindAccess is the kind of a wait on an access (novox/hq ADR 0325 §7; mesh-host internal/link).
const WaitKindAccess = "access"
// AccessWait is one access waiting for the operator (novox/hq ADR 0325 §7): the access by its module's own id for
// it, the part (its module), and what the operator gives, in words with no path — "let the mesh keep the shared
// folder movies", or "mount the disk that holds books". Beside Waits because mesh-sdk's health.Wait names a secret or
// a setting and nothing else. From an engine at IdentityContract or later.
type AccessWait struct {
Kind string `json:"kind"`
Access string `json:"access"`
Part string `json:"part"`
What string `json:"what"`
// Placed names, with no path, the filesystem the folder is on now ("fs-<12 hex>"), on a wait for the operator's
// word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks
// again (mesh-host internal/link AccessWait.Placed).
Placed string `json:"placed,omitempty"`
// Reason is what the wait is for: WaitKeep (the operator's word to take the library) or WaitDisk (its disk
// mounted).
Reason string `json:"reason"`
}
// The reasons of an access wait (mesh-host internal/link).
const (
WaitKeep = "keep"
WaitDisk = "disk"
)
// KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user
// has its right, waiting while the mesh may not keep its folder yet or its disk is not there, unhealthy otherwise —
// and unknown when its filesystem could not be named this look (a hung mount), carrying no wait.
const KindAccess = "access"
// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet
// because its disk was away (novox/hq ADR 0325 §8; mesh-host internal/link): unhealthy, said under the access that
// last named it (`<access id>~entries`), which may no longer be declared. The node-engine's own.
const KindKeptEntries = "kept-entries"
// ResourceHealth is one long-running resource's state.
type ResourceHealth struct {
Module string `json:"module"`
@@ -606,6 +667,10 @@ type ResourceHealth struct {
// Waits is what a resource in StateWaiting waits for (novox/hq ADR 0283). Empty otherwise, and from an
// engine older than that.
Waits []Wait `json:"waits,omitempty"`
// AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7); Act the last walk under its
// folder, kept as the engine said it. From an engine at IdentityContract or later.
AccessWaits []AccessWait `json:"access-waits,omitempty"`
Act json.RawMessage `json:"act,omitempty"`
}
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
+114
View File
@@ -0,0 +1,114 @@
package link
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
)
// The take act: a shared folder that already holds data is taken by the mesh once, on the operator's word
// (novox/hq ADR 0325 §8).
//
// The controller asks the operator once for that folder on that machine (ADR 0259, at the level approve). On the
// warrant it asks the machine's node-engine, on the engine's own subject (broker.AskAccessActSubject), signed with
// the mesh's key as a hand-over is (SignedHandOver) under a signing context of its own (AccessActContext), to take
// the folder: the engine adds the entries of the users it was declared for that folder to everything under it —
// following no link, entering no other filesystem — answers at once that it started or why it refused, and states
// the walk on the access's verdict. **Nothing is taken without a warrant**: the ask names the warrant the operator
// gave, and the controller sends it only from the warrant (cmd/mesh-controller decideTake).
//
// The engine's own shape (mesh-host internal/link AccessActAsk, AnswerAccessAct): a hand-over's fields and
// {act: "take", access: <the access's resource id in the declaration>, warrant}.
// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's signature, nor a
// declaration's. The engine holds the same words.
const AccessActContext = "novox-mesh access-act v2\n"
// ActTake is the one act the controller asks.
const ActTake = "take"
// AccessActAsk is what the controller asks: a hand-over's fields — the node it is for, the folder's absolute path as
// the declaration places it, who asked, when the ask stops being good and a nonce the engine takes once — and the
// act's own: which act, the access by its resource id, and the warrant the operator gave.
type AccessActAsk struct {
Node string `json:"node"`
Path string `json:"path"`
By string `json:"by"`
Expires time.Time `json:"expires"`
Nonce string `json:"nonce"`
Act string `json:"act"`
Access string `json:"access"`
Warrant string `json:"warrant"`
}
// MaxActField bounds an access id and a warrant, as the engine does.
const MaxActField = 200
// TakeWithin is how long the controller waits for the engine's answer: it starts the walk and answers, and the
// walk's end is stated on the access's verdict.
const TakeWithin = 30 * time.Second
// SignTake fills a take ask's act, expiry and nonce and signs it with the mesh's key over AccessActContext. It refuses
// an ask with no warrant, no path or no access — and one the engine would refuse for its shape: nothing is ever asked
// of an engine that it would have to refuse.
func SignTake(ctx context.Context, signer Signer, ask AccessActAsk) ([]byte, error) {
ask.Access, ask.Warrant = strings.TrimSpace(ask.Access), strings.TrimSpace(ask.Warrant)
switch {
case signer == nil:
return nil, errors.New("no signing key, so nothing can be asked of an engine")
case ask.Warrant == "":
return nil, errors.New("a take is asked only on the operator's warrant, and this one names none")
case ask.Node == "" || !strings.HasPrefix(ask.Path, "/"):
return nil, errors.New("a take names its machine and the folder's absolute path")
case ask.Access == "":
return nil, errors.New("a take names the access whose folder it takes")
}
// The warrant is one short name; the access is the resource id the declaration gave it, which may hold a path's
// slashes (catalogue.AccessID) — the engine judges it against what it was declared.
if len(ask.Warrant) > MaxActField || strings.ContainsAny(ask.Warrant, "\r\n\x00/") {
return nil, fmt.Errorf("the take's warrant %q is not one short name", ask.Warrant)
}
if len(ask.Access) > MaxActField || strings.ContainsAny(ask.Access, "\r\n\x00") {
return nil, fmt.Errorf("the take's access %q is not one line", ask.Access)
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ask.Act, ask.Nonce, ask.Expires = ActTake, hex.EncodeToString(nonce), time.Now().UTC().Add(HandOverGood)
raw, err := json.Marshal(ask)
if err != nil {
return nil, err
}
signature, err := signer.Sign(ctx, append([]byte(AccessActContext), raw...))
if err != nil {
return nil, fmt.Errorf("cannot sign the ask: %w", err)
}
return json.Marshal(SignedHandOver{Ask: raw, Signature: signature})
}
// AskTake asks one machine's node-engine to take a shared folder on the operator's warrant, and reads its answer. An
// error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's and comes back in
// the answer.
func AskTake(ctx context.Context, conn *nats.Conn, signer Signer, ask AccessActAsk, timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := SignTake(ctx, signer, ask)
if err != nil {
return HandOverAnswer{}, err
}
return askSigned(ctx, conn, broker.AskAccessActSubject(ask.Node), body, ask.Node, timeout, askWords{
what: "the take of a shared folder", nothing: "nothing was taken", issue: "ADR 0325",
unknown: "whether it started is not known — the access's verdict on the machine says whether it is kept",
record: "a start"})
}
+1 -1
View File
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
a second place to clean besides the catalogue itself.
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile
mesh-host a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
mesh-host d8e785bb4b4bbd4ad99dccec60c588032cd78fa6 examples/foundation-first-node-nats.lock
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
@@ -523,6 +523,20 @@ func refusedPlaces(resources []declaration.Resource) map[string]error {
switch res := r.(type) {
case *declaration.Directory:
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
// link another account could make in its path refuses it, and with it what is below it, whatever
// order they are declared in. The apply walks again at the moment of use. A directory handed over
// keeps its own, stricter walk (issue 356), which refuses any link at all.
if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" &&
!HandedOver()[handOverKey(res.ID, res.Path)] {
if judged := judgeMeshDir(res.Path); judged != nil {
err = judged
var link *LinkInPathError
if errors.As(judged, &link) {
err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()}
}
}
}
case *declaration.Access:
err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "")
default:
+12
View File
@@ -35,3 +35,15 @@
./cmd/mesh-controller TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend
./cmd/mesh-controller TestAGateJudgesTheOtherMovesBesideAKeptCore
./cmd/mesh-controller TestReplay401
# 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
./cmd/mesh-controller TestALibrarysVerdictIsSaidForTheModulesThatUseIt
./cmd/mesh-controller TestAHeldModuleIsComposedAsBeforeItJoined
./cmd/mesh-controller TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone
./internal/inventory TestTheSubPathsOfModulesPulledInByARequirementAreKept
./internal/inventory TestALibraryIsTakenWhenTheActIsDone
+335 -4
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net"
"path/filepath"
"reflect"
"regexp"
"slices"
@@ -456,14 +457,51 @@ const (
// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own —
// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A
// media library, a download spool is the operator's: it existed before the mesh, several modules
// read and write it at once, and the mesh must not create, chown, reconcile or remove it. The
// host confirms it is there and mounts it; nothing else.
// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host
// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328),
// not an access.
type Access struct {
ID string `json:"id"`
Type Type `json:"type"`
Path string `json:"path"`
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"`
// Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and
// that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not
// acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept.
Users []AccessUser `json:"users,omitempty"`
Taken bool `json:"taken,omitempty"`
// Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted
// so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5).
Grant json.RawMessage `json:"grant,omitempty"`
}
// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4).
const (
FromContainer = "container"
FromToolRunner = "tool-runner"
)
// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its
// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name.
type AccessUser struct {
Identity string `json:"identity"`
UID int `json:"uid,omitempty"`
Use string `json:"use"`
From string `json:"from"`
}
// UnmarshalJSON reads a user as strictly as a resource is read.
func (u *AccessUser) UnmarshalJSON(raw []byte) error {
type plain AccessUser
var p plain
if err := strictDecode(raw, &p); err != nil {
return fmt.Errorf("an access's user: %w", err)
}
*u = AccessUser(p)
return nil
}
func (a *Access) Identity() string { return a.ID }
@@ -482,9 +520,43 @@ func (a *Access) validate(where string, _ bool) []string {
problems = append(problems, fmt.Sprintf(
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
}
for _, u := range a.Users {
switch {
case u.Identity == "" || !accountName.MatchString(u.Identity):
problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one",
where, u.Identity))
case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner):
// Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a
// number below it is no account. A tool runner's account alone may come without its number: the
// controller cannot know the operator's account's, and the engine reads it by name
// (apply.ResolveRunners), refusing one that is root's.
problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+
"root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID))
}
if u.Use != AccessRead && u.Use != AccessReadWrite {
problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where,
u.Identity, u.Use, AccessRead, AccessReadWrite))
}
if u.Use == AccessReadWrite && a.Mode != AccessReadWrite {
problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where,
u.Identity, orRead(a.Mode)))
}
if u.From != FromContainer && u.From != FromToolRunner {
problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where,
u.Identity, u.From, FromContainer, FromToolRunner))
}
}
return problems
}
// orRead is an access's mode as said: absent narrows to read.
func orRead(mode string) string {
if mode == "" {
return AccessRead
}
return mode
}
func (u *User) Identity() string { return u.ID }
func (u *User) Kind() Type { return TypeUser }
func (u *User) Target() string { return u.Name }
@@ -1160,6 +1232,78 @@ type Container struct {
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
Health *Health `json:"health,omitempty"`
// RunAs is who the container runs as, resolved by the controller from the module's identity and the
// image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and
// PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
// a resource's Identity is its id.
RunAs *ContainerIdentity `json:"identity,omitempty"`
}
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
const (
IdentityByEnv = "env"
IdentityByRun = "run"
)
// ContainerIdentity is who a container runs as, and how its image is told.
type ContainerIdentity struct {
// Mode is IdentityByEnv or IdentityByRun.
Mode string `json:"mode"`
// UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for
// IdentityByEnv: "PUID", "PGID".
UserEnv string `json:"user_env,omitempty"`
GroupEnv string `json:"group_env,omitempty"`
UID int `json:"uid"`
GID int `json:"gid"`
}
// UnmarshalJSON reads an identity as strictly as a resource is read.
func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error {
type plain ContainerIdentity
var p plain
if err := strictDecode(raw, &p); err != nil {
return fmt.Errorf("a container's identity: %w", err)
}
*i = ContainerIdentity(p)
return nil
}
// envName is a variable's name as a runtime takes it.
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func (i *ContainerIdentity) problems(where string, env map[string]string) []string {
if i == nil {
return nil
}
var problems []string
switch i.Mode {
case IdentityByEnv:
if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) {
problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+
"it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv))
}
for _, name := range []string{i.UserEnv, i.GroupEnv} {
if _, set := env[name]; set && name != "" {
problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+
"for one variable", where, name))
}
}
case IdentityByRun:
if i.UserEnv != "" || i.GroupEnv != "" {
problems = append(problems, where+": an identity told by the runtime's user option names no variable")
}
default:
problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where,
IdentityByEnv, IdentityByRun, i.Mode))
}
// Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started.
if i.UID <= 0 || i.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+
"never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID))
}
return problems
}
func (c *Container) Identity() string { return c.ID }
@@ -1242,6 +1386,7 @@ func (c *Container) validate(where string, _ bool) []string {
problems = append(problems, where+": networks is for a container that keeps running; a step "+
"runs and exits, and joins nothing afterwards")
}
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
return append(problems, checkImage(where, c.Image)...)
}
@@ -1392,6 +1537,179 @@ type Declaration struct {
// the generation of what it puts back, which may be older than what the machine applied, and is
// not refused for it.
PutBack bool
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account
// and a group `mesh-<module>` with the numbers the controller allocated once for the module, the same on
// every machine. The engine makes each before any resource is applied, and removes what it made of one no
// longer declared. Absent is a controller older than ADR 0325.
Identities []Identity
// Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-<library>`
// with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of
// one no longer declared. Absent is a controller older than ADR 0328.
Groups []Group
// Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether
// this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for,
// and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328.
Libraries []Library
}
// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-<module>` with
// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group),
// and the groups of its other libraries as added groups.
type Identity struct {
Module string `json:"module"`
Name string `json:"name"`
UID int `json:"uid"`
GID int `json:"gid"`
Groups []int `json:"groups,omitempty"`
}
// Group is one library's group, as the controller allocated it (ADR 0328 §3).
type Group struct {
Name string `json:"name"`
GID int `json:"gid"`
}
// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's.
const GroupPrefix = "mesh-"
// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:<name>`.
const LibraryAccessPrefix = "library:"
// Library is one library as this machine sees it (ADR 0328).
type Library struct {
Library string `json:"library"`
// Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts
// module mounts it at.
Root string `json:"root"`
Group string `json:"group"`
GID int `json:"gid"`
// Home is that the library's data lives on this machine: only there does the engine keep it.
Home bool `json:"home"`
// Taken is the controller saying the operator's warrant to take it was carried out (§7).
Taken bool `json:"taken,omitempty"`
// Paths are the sub-paths its modules ask for, relative to the root.
Paths []string `json:"paths,omitempty"`
// Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it.
Runner string `json:"runner,omitempty"`
}
// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never
// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest
// (§13).
func checkLibraries(groups []Group, libs []Library) []string {
var problems []string
byName := map[string]int{}
numbers := map[int]string{}
for _, g := range groups {
where := fmt.Sprintf("group %q", g.Name)
if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) {
problems = append(problems, fmt.Sprintf("%s: a library's group is named %s<library>", where, GroupPrefix))
}
if g.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+
"below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID))
}
if _, twice := byName[g.Name]; twice {
problems = append(problems, where+": declared twice")
}
if other, used := numbers[g.GID]; used {
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other))
}
byName[g.Name], numbers[g.GID] = g.GID, g.Name
}
seen := map[string]bool{}
for _, l := range libs {
where := fmt.Sprintf("library %q", l.Library)
switch {
case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"):
problems = append(problems, where+": a library is named by one word")
case seen[l.Library]:
problems = append(problems, where+": declared twice")
}
seen[l.Library] = true
if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" {
problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where,
l.Root))
}
if gid, ok := byName[l.Group]; !ok || gid != l.GID {
problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes",
where, l.Group, l.GID))
}
if l.Group != GroupPrefix+l.Library {
problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix,
l.Library, l.Group))
}
for _, p := range l.Paths {
if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") {
problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p))
}
}
if l.Runner != "" && !accountName.MatchString(l.Runner) {
problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner))
}
}
for i, a := range libs {
for _, b := range libs[i+1:] {
if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") ||
strings.HasPrefix(b.Root, a.Root+"/")) {
problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+
"never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library))
}
}
}
return problems
}
// IdentityPrefix starts every module identity's name.
const IdentityPrefix = "mesh-"
// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number
// twice — two modules with one account would write as each other.
func checkIdentities(ids []Identity, groups []Group) []string {
var problems []string
names, uids := map[string]bool{}, map[int]string{}
libraryGroups := map[int]bool{}
for _, g := range groups {
libraryGroups[g.GID] = true
}
for _, id := range ids {
where := fmt.Sprintf("identity %q", id.Name)
if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) {
problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s<module>, and this one is "+
"for the module %q", where, IdentityPrefix, id.Module))
}
if id.UID <= 0 || id.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+
"nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where,
id.UID, id.GID))
}
if names[id.Name] {
problems = append(problems, where+": declared twice")
}
if other, used := uids[id.UID]; used {
problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other))
}
// Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is
// every added group. A module's own number is never a library group's.
if id.GID != id.UID && !libraryGroups[id.GID] {
problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+
"library's this declaration makes", where, id.GID, id.UID))
}
if libraryGroups[id.UID] {
problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID))
}
for _, g := range id.Groups {
if !libraryGroups[g] {
problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+
"declaration makes", where, g))
}
}
names[id.Name], uids[id.UID] = true, id.Name
}
return problems
}
// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the
@@ -1561,6 +1879,13 @@ type envelope struct {
// sends them only to a host whose reports say `reads_generation`.
Generation int64 `json:"generation,omitempty"`
PutBack bool `json:"put_back,omitempty"`
// Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a
// controller sends it only to a host whose health says link.IdentityContract.
Identities []Identity `json:"identities,omitempty"`
// Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller
// sends them only to a host whose health says link.LibraryContract.
Groups []Group `json:"groups,omitempty"`
Libraries []Library `json:"libraries,omitempty"`
}
func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -1578,8 +1903,10 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
}
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack}
var problems []string
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries}
problems := checkIdentities(env.Identities, env.Groups)
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
if env.Generation < 0 {
// Below zero would read as "none claimed" and pass every refusal of an older generation.
problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+
@@ -1732,6 +2059,10 @@ func checkWhileStopped(resources []Resource) []string {
return problems
}
// accountName is what an identity's or an access user's name may be: the portable account name the user tools
// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument.
var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`)
func strictDecode(raw []byte, into any) error {
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
// field the host does not know is a thing the control plane believes it asked for.
+2 -2
View File
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
# github.com/nats-io/nuid v1.0.1
## explicit
github.com/nats-io/nuid
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b
## explicit; go 1.26.0
github.com/novox/mesh-host/internal/declaration
github.com/novox/mesh-host/rootsearch
@@ -138,4 +138,4 @@ golang.org/x/text/width
# golang.org/x/time v0.15.0
## explicit; go 1.25.0
golang.org/x/time/rate
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b