diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 4bf3b117..23eaa01a 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -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 shared folder the operator is asked to let the mesh keep (kept_folders.go, novox/hq ADR 0325 §8): + // about no condition, taken on Approve by the serving controller itself, asked once per folder and machine. + Take *folderTake `json:"take,omitempty"` } // 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 shared folder waiting to be kept (novox/hq ADR 0325 §8), and take what takes one on the + // operator's warrant; nil asks about none. machines are the mesh's machine names, allowed in an ask's words. + folders func(ctx context.Context) ([]folderTake, error) + 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 shared folder waiting to be kept, once per folder and machine (novox/hq ADR 0325 §8). + if err := a.askFolders(ctx); err != nil { + a.logf("the operator could not be asked about the folders waiting to be kept: %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 shared folder taken on the operator's warrant (novox/hq ADR 0325 §8): 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 { + 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: diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go index aadff952..c4bd0d13 100644 --- a/cmd/mesh-controller/asker_wire.go +++ b/cmd/mesh-controller/asker_wire.go @@ -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 diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index 4ccf7b5f..d40e05c9 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -152,6 +152,16 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { } } + // **What ADR 0325 refuses once a module runs as its own identity is warned of until then** (novox/hq ADR 0325 §1–§3): + // a directory naming its owner, a container declaring no identity that mounts the module's own directory or a + // read-write access. Printed, never counted: every registered container module does one of them today, and the + // catalogues migrate against these lines. A module that declares identity is refused them instead (ParseManifest). + for _, name := range names { + for _, w := range catalogue.IdentityWarnings(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 diff --git a/cmd/mesh-controller/kept_folders.go b/cmd/mesh-controller/kept_folders.go new file mode 100644 index 00000000..b13a7942 --- /dev/null +++ b/cmd/mesh-controller/kept_folders.go @@ -0,0 +1,354 @@ +package main + +// The mesh takes a shared folder that already holds data once, on the operator's word (novox/hq ADR 0325 §7, §8). +// +// **What waits.** A node-engine that keeps folders states an access waiting, naming it (inventory.Wait.Access), while +// its folder is there and has not been taken; the controller excuses that wait only for a declared, placed folder not +// yet taken (checkAccessWait), and the module is said as its needs-operator condition, in words with no path. +// +// **One ask per folder and machine.** The serving controller's asker asks the operator once for each such folder on +// each machine — however many modules wait on it — at the level approve (ADR 0259), in the clause's words: "Let the +// mesh keep on , now for : …". It is kept in the controller's asks beside a proposal +// (asked.Take), never a condition's: the condition is the module's, and two modules waiting on one folder are one +// question. While it is open no second is asked; one that expired unanswered is asked again while the folder waits; +// one about a folder that no longer waits is taken back. None is asked for a folder already taken, so a module that +// joins a taken folder is asked nothing (its access wait is not excused, and its entries are added at apply). +// +// **On the warrant, and only on it**, the controller asks the machine's engine to take the folder (link.AskTake), +// naming the access by its resource id and the warrant; the engine adds the entries of the users it was declared for +// that folder. On its word that it started, the folder is recorded taken (kept_folder), and from the next send every +// access to it says `taken`. Whether the filesystem must first allow ACLs is the engine's to see and set: its wait +// does not say so to the controller, so the ask does not either. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/outward" +) + +// folderTake is one folder the operator is asked to let the mesh keep, as the controller's ask keeps it +// (asked.Take). Every field the ask is composed from is here, so the act on the warrant is composed from the record. +type folderTake struct { + Node string `json:"node"` + Path string `json:"path"` + // Access is the resource id of one access to it in the machine's declaration: what the take act names. + Access string `json:"access"` + // Accesses are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted. + Accesses []string `json:"accesses"` + Modules []string `json:"modules"` + At time.Time `json:"at"` +} + +// takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant. +const takeVerb = askerName + ".take" + +// key is the folder on its machine: what one ask is kept for. +func (t folderTake) key() string { return t.Node + "\x00" + filepath.Clean(t.Path) } + +// digest binds everything the take does and was said for: the machine, the folder, the access it names and the +// modules the operator was told. +func (t folderTake) digest() string { + raw, _ := json.Marshal(struct { + Node, Path, Access string + Modules []string + }{t.Node, filepath.Clean(t.Path), t.Access, 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 folderTake) actions(id string) []conditions.Action { + return []conditions.Action{{Label: "Let the mesh keep it", Verb: takeVerb, Machine: t.Node, + Level: conditions.LevelApprove, Arguments: map[string]string{"take": id, "node": t.Node, + "path": filepath.Clean(t.Path), "access": t.Access, "digest": t.digest()}}} +} + +// takeWords is the clause's ask (ADR 0325 §8), with the folder as shown: "Let the mesh keep on , now +// for : …". +func takeWords(shown, machine string, modules []string) string { + return fmt.Sprintf("Let the mesh keep %s on %s, now for %s: it adds their access entries, and those of any "+ + "module given access later, to every folder and file there; the group bits `ls -l` shows become the "+ + "entries' mask, and owners, groups and every other right stay as they are", shown, machine, + strings.Join(modules, ", ")) +} + +// ask is the take's ask, composed from it alone. The folder's path is said whole where the router's content rule +// lets it leave the mesh, and otherwise named by the shared folder it is ("the shared folder movies"), which is the +// access the module's wait names. +func (t folderTake) ask(id string, machines []string) (asks.Ask, map[string]int) { + shown := filepath.Clean(t.Path) + if _, ok := outward.Check(shown, machines...); !ok { + shown = "the shared folder " + strings.Join(t.Accesses, ", ") + } + headline := fmt.Sprintf("Let the mesh keep a shared folder on %s?", t.Node) + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Let the mesh keep a shared folder?" + } + explanation := takeWords(shown, t.Node, t.Modules) + "." + if _, ok := outward.Check(explanation, machines...); !ok { + explanation = sayable(explanation, machines) + } + q := asks.Ask{ID: id, Headline: headline, Explanation: explanation, + Who: asks.Operator, Expires: t.At.Add(askApproveFor), + OnExpiry: "nothing is changed, and you are asked again while the folder waits", About: "take." + t.Node} + options := map[string]int{} + for i, act := range t.actions(id) { + binds, _ := asks.ActDigest(boundAct(act)) + oid := optionID(act.Label) + options[oid] = i + q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Level: asks.Level(act.Level), Binds: binds, + Does: "the mesh adds the access entries there, and keeps them from then on without asking"}) + } + return q, options +} + +// takeOnWarrant performs a take the operator approved: the engine is asked, and the folder recorded taken on its +// word. It answers what the engine said. +type takeOnWarrant func(ctx context.Context, t folderTake, askID, by string) (string, error) + +// askFolders asks the operator about every folder waiting to be kept that is not asked about yet, and takes back an +// open ask about one that no longer waits. Called at the end of each reconcile, after the conditions' asks, within +// the asks the router holds open at once. +func (a *asker) askFolders(ctx context.Context) error { + if a.folders == nil { + return nil + } + wanted, err := a.folders(ctx) + if err != nil { + return err + } + now := a.now() + all, err := a.store.All(ctx) + if err != nil { + return err + } + open, openNow := map[string]asked{}, 0 + for _, r := range all { + if r.State != askOpen || !now.Before(r.Ask.Expires) { + continue + } + openNow++ + if r.Take != nil { + open[r.Take.key()] = r + } + } + want := map[string]bool{} + for _, t := range wanted { + want[t.key()] = true + } + for key, r := range open { + if !want[key] { + if err := a.cancel(ctx, r, "the folder no longer waits to be kept"); err != nil { + return err + } + openNow-- + } + } + var machines []string + if a.machines != nil { + if machines, err = a.machines(ctx); err != nil { + return err + } + } + for _, t := range wanted { + if _, asking := open[t.key()]; asking { + continue // one ask per folder and machine (§8) + } + if openNow >= askMostOpen { + return nil // asked when one of the open ones ends + } + t.At = now + id := newAskID() + q, options := t.ask(id, machines) + if err := q.Check(now); err != nil { + a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err) + continue + } + body, err := json.Marshal(q) + if err != nil { + return err + } + take := t + if err := a.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: t.actions(id), + Options: options, State: askOpen, Opened: now, Take: &take}); err != nil { + return fmt.Errorf("the take could not be kept in the controller's asks, so it was not asked: %w", err) + } + if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil { + _, _ = a.store.Change(ctx, id, func(x *asked) bool { + if x.State != askOpen || x.Acted != "" { + return false + } + x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error() + return true + }) + a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err) + continue + } + openNow++ + a.logf("asked the operator to let the mesh keep a folder on %s for %s (%s)", t.Node, + strings.Join(t.Modules, ", "), id) + } + return nil +} + +// decideTake is what the asker does with a warrant for a take (asker.Decided): the act only when it is the one the +// option bound — composed again from the record, the users' digest among it — and then the engine is asked. +func (a *asker) decideTake(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) { + t := *r.Take + recomposed := t.actions(r.ID) + option, offered := r.Ask.Option(w.Option) + if !offered || len(recomposed) != 1 { + return "", fmt.Errorf("the take %s offers no answer %q: nothing is taken", r.ID, w.Option) + } + if err := option.Performs(boundAct(recomposed[0])); err != nil { + return "", fmt.Errorf("the take kept for %s is not the one the operator was shown: %v", r.ID, err) + } + if err := option.Performs(boundAct(act)); err != nil { + return "", fmt.Errorf("the act chosen for %s is not the one the operator was shown: %v", r.ID, err) + } + if a.take == nil { + return "", errors.New("this controller cannot take a folder on a warrant") + } + by := fmt.Sprintf("approved by %s via %s at %s (ask %s)", byWords(w), viaWords(w), + w.At.Local().Format("2006-01-02 15:04"), r.ID) + return a.take(ctx, t, r.ID, by) +} + +// takeFolderIn is takeOnWarrant on this controller's stores and bus: the engine is asked with the mesh's key, naming the +// ask the warrant answered; on its word that it started, the folder is recorded taken. A refusal records nothing. +func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error)) takeOnWarrant { + return func(ctx context.Context, t folderTake, askID, by string) (string, error) { + // Who asked, as one short line the engine takes (at most link.MaxHandOverBy): the ask names the rest. + short := by + if len(short) > 180 { + short = "the operator, on the warrant for ask " + askID + } + answer, err := ask(ctx, link.AccessActAsk{Node: t.Node, Path: filepath.Clean(t.Path), By: short, + Access: t.Access, Warrant: askID}) + if err != nil { + return "", err + } + if answer.Refused != "" { + return "", fmt.Errorf("%s refused to take the folder: %s", t.Node, answer.Refused) + } + if err := open.inventory.RecordTaken(ctx, t.Node, inventory.TakenFolder{Path: t.Path, By: by, Ask: askID, + Warrant: askID, Said: answer.Said}); err != nil { + return "", fmt.Errorf("%s started the take (%s), and it could NOT be recorded: %w", t.Node, answer.Said, err) + } + return fmt.Sprintf("%s takes the folder for %s: %s", t.Node, strings.Join(t.Modules, ", "), answer.Said), nil + } +} + +// waitingFolder is one access wait excused for a folder not yet taken, as foldersWaiting gathers them. +type waitingFolder struct { + module, access string + // resource is the access's resource id on the machine, as the engine stated it. + resource string +} + +// foldersWaiting is every folder waiting to be kept across the mesh (ADR 0325 §7, §8): from each machine's newest +// statement, each access wait of a module that is excused (checkWaiting) and waits for the operator's word rather +// than for a disk, grouped by machine and folder. +func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) ( + map[string]map[string][]waitingFolder, operatorWaitFacts) { + var f operatorWaitFacts + out := map[string]map[string][]waitingFolder{} + machines := make([]string, 0, len(healths)) + for m := range healths { + machines = append(machines, m) + } + sort.Strings(machines) + for _, machine := range machines { + rs := healths[machine].Resources + var modules []string + for _, r := range rs { + for _, w := range r.Waits { + if r.State == link.StateWaiting && w.Access != "" && !w.Disk { + modules = append(modules, r.Module) + } + } + } + if len(modules) == 0 { + continue + } + readWaitFacts(ctx, inv, machine, modules, nil, &f) + for _, r := range checkWaiting(machine, rs, f) { + if r.State != link.StateWaiting { + continue + } + for _, w := range r.Waits { + if w.Access == "" || w.Disk { + continue + } + path, placed := f.placed[r.Module+"@"+machine][w.Access] + if !placed { + continue + } + if out[machine] == nil { + out[machine] = map[string][]waitingFolder{} + } + out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access, + resource: r.Resource}) + } + } + } + return out, f +} + +// foldersIn is the asker's folders on this controller's stores: every folder waiting to be kept, each with the +// modules waiting on it and the access the take names (the first waiting module's, by its resource id). Who the +// take adds entries for is what the engine was declared for that folder: the ask names the modules. +func foldersIn(open *stores) func(ctx context.Context) ([]folderTake, error) { + return func(ctx context.Context) ([]folderTake, error) { + healths, err := open.inventory.Healths(ctx) + if err != nil { + return nil, err + } + waiting, _ := foldersWaiting(ctx, open.inventory, healths) + return takesOf(waiting), nil + } +} + +// takesOf is one take per machine and folder from the waits gathered, sorted. +func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake { + var out []folderTake + for machine, folders := range waiting { + for path, ws := range folders { + t := folderTake{Node: machine, Path: path} + seenM, seenA := map[string]bool{}, map[string]bool{} + var resources []string + for _, w := range ws { + if !seenM[w.module] { + seenM[w.module] = true + t.Modules = append(t.Modules, w.module) + } + if !seenA[w.access] { + seenA[w.access] = true + t.Accesses = append(t.Accesses, w.access) + } + resources = append(resources, w.resource) + } + sort.Strings(t.Modules) + sort.Strings(t.Accesses) + sort.Strings(resources) + t.Access = resources[0] + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() }) + return out +} diff --git a/cmd/mesh-controller/kept_folders_test.go b/cmd/mesh-controller/kept_folders_test.go new file mode 100644 index 00000000..85f292f0 --- /dev/null +++ b/cmd/mesh-controller/kept_folders_test.go @@ -0,0 +1,360 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// novox/hq ADR 0325 §7 and §8 on the controller's side: an access wait is excused only for a declared, placed folder +// not yet taken, or one whose disk is not there; the gate passes it as a wait for a person and the operator reads it +// as needs-operator in words with no path; the operator is asked once per folder and machine, never about a folder +// taken; and the folder is taken only on the warrant. + +var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1", + Accesses: []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}} + +var plexManifest = catalogue.Manifest{Module: "plex", Version: "1", + Accesses: []catalogue.Access{{ID: "films", Path: "/services/media/movies", Mode: catalogue.AccessRead}}} + +const moviesAt = "/storage/media/movies" + +func keepWait(access string) inventory.Wait { + return inventory.Wait{Part: "radarr", Access: access, What: "let the mesh keep the shared folder " + access} +} + +func diskWait(access string) inventory.Wait { + return inventory.Wait{Part: "radarr", Access: access, Disk: true, What: "mount the disk that holds " + access} +} + +func accessFacts(taken ...string) operatorWaitFacts { + kept := map[string]bool{} + for _, p := range taken { + kept[p] = true + } + return operatorWaitFacts{ + manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, + given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}}, + placed: map[string]map[string]string{"radarr@ace": {"movies": moviesAt}, "plex@ace": {"films": moviesAt}, + "radarr@novox": {}}, + taken: map[string]map[string]bool{"ace": kept}, + } +} + +// Row 7: excused for a declared, placed folder not yet taken, and for an absent folder whose disk is not there (taken +// or not); judged unhealthy when undeclared, unplaced, already taken, or naming a secret beside it. That a folder is +// root's is the engine's to see: it states that unhealthy and never waiting (§12). +func TestAnAccessWaitIsExcusedOnlyForAFolderTheMeshMayNotKeepYet(t *testing.T) { + for _, c := range []struct { + name string + machine string + w inventory.Wait + f operatorWaitFacts + says string + }{ + {"declared, placed, not taken", "ace", keepWait("movies"), accessFacts(), ""}, + {"its disk not there", "ace", diskWait("movies"), accessFacts(), ""}, + {"its disk not there, the folder taken before", "ace", diskWait("movies"), accessFacts(moviesAt), ""}, + {"already taken", "ace", keepWait("movies"), accessFacts(moviesAt), "taken on ace already"}, + {"undeclared", "ace", keepWait("books"), accessFacts(), "does not declare"}, + {"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"}, + {"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests, + placed: accessFacts().placed}, "could not be read"}, + {"an access and a secret", "ace", inventory.Wait{Part: "p", Access: "movies", Secret: "x", What: "w"}, + accessFacts(), "one secret or one setting, or one access"}, + } { + err := checkWait("radarr", c.machine, c.w, c.f) + switch { + case c.says == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says) + } + } +} + +func accessWaiting(module, access string, w inventory.Wait) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: module, Resource: module + "." + catalogue.AccessID("/services/media/movies"), + Kind: "access", Target: moviesAt, State: link.StateWaiting, Waits: []inventory.Wait{w}} +} + +// Row 7, the gate and the condition: both excused waits pass the first-node gate as a wait for a person, and the +// module is said as needs-operator — in words that carry no path. +func TestAnAccessWaitPassesTheGateAndAsksForTheOperatorWithoutAPath(t *testing.T) { + now := time.Now() + for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} { + w.What += " at " + moviesAt // an engine's words carrying a path never reach the operator's + f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", w)}}}} + h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f) + if h != healthPerson || !strings.Contains(why, "waits for the operator") { + t.Fatalf("an excused access wait reads %v %q; want a wait for a person", h, why) + } + if strings.Contains(why, "/") { + t.Errorf("the gate's verdict carries a path: %q", why) + } + o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil) + if o.Kind != kindNeedsOperator { + t.Fatalf("raised as %s", o.Kind) + } + for _, words := range []string{o.Summary, o.Headline, o.Explanation, o.Needs} { + if strings.Contains(words, "/") { + t.Errorf("the operator reads a path: %q", words) + } + } + want := "let the mesh keep the shared folder movies on ace when you are asked" + if w.Disk { + want = "mount the disk that holds movies on ace" + } + if !strings.Contains(o.Needs, want) { + t.Errorf("needs-operator asks %q; want %q", o.Needs, want) + } + } + // Taken already: not excused, and the gate does not pass it. + f := gateFacts{now: now, waits: accessFacts(moviesAt), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", keepWait("movies"))}}}} + if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet || + !strings.Contains(why, "taken") { + t.Fatalf("a wait on a taken folder reads %v %q", h, why) + } +} + +// The engine's access-waits are kept as waits naming the access, and its words tell a disk from a keep. +func TestTheEnginesAccessWaitsAreReadAsWaits(t *testing.T) { + for what, disk := range map[string]bool{"let the mesh keep the shared folder movies": false, + "mount the disk that holds books": true} { + if got := (link.AccessWait{Kind: link.WaitKindAccess, Access: "movies", Part: "radarr", What: what}).WaitsForDisk(); got != disk { + t.Errorf("%q waits for a disk: %v", what, got) + } + } +} + +// waitingOn is foldersWaiting's gathering for one folder: the given modules waiting on it by their access. +func waitingOn(machine string, ws ...waitingFolder) map[string]map[string][]waitingFolder { + return map[string]map[string][]waitingFolder{machine: {moviesAt: ws}} +} + +func takeRig(t *testing.T, waiting func() map[string]map[string][]waitingFolder) (*askerRig, *[]string) { + r := newAskerRig(t) + var taken []string + r.a.folders = func(context.Context) ([]folderTake, error) { return takesOf(waiting()), nil } + r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil } + r.a.take = func(_ context.Context, ft folderTake, askID, by string) (string, error) { + taken = append(taken, ft.Node+" "+ft.Path+" "+ft.Access+" "+askID+" "+by) + return "taking", nil + } + return r, &taken +} + +func takeAsks(r *askerRig) []asked { + var out []asked + for _, a := range r.store { + if a.Take != nil { + out = append(out, a) + } + } + return out +} + +// Row 8, the controller's part: one ask per folder and machine however many modules wait on it, in the clause's +// words; none again while it is open; taken back when the folder no longer waits; none for a folder taken (its waits +// are not excused, so it is never gathered). +func TestTheOperatorIsAskedOncePerFolderAndMachine(t *testing.T) { + both := waitingOn("ace", + waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-services/media/movies"}, + waitingFolder{module: "plex", access: "films", resource: "plex.access-services/media/movies"}) + current := both + r, _ := takeRig(t, func() map[string]map[string][]waitingFolder { return current }) + ctx := context.Background() + for i := 0; i < 3; i++ { + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + } + kept := takeAsks(r) + if len(kept) != 1 { + t.Fatalf("%d asks for one folder on one machine", len(kept)) + } + q := kept[0].Ask + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } + if q.Highest() != asks.Approve || len(q.Options) != 1 { + t.Fatalf("the ask offers %+v; want one answer at approve", q.Options) + } + // The path may not leave the mesh (the router's content rule): the folder is named by the shared folder it is. + want := "Let the mesh keep the shared folder films, movies on ace, now for plex, radarr: it adds their access " + + "entries, and those of any module given access later, to every folder and file there; the group bits `ls -l` " + + "shows become the entries' mask, and owners, groups and every other right stay as they are." + if q.Explanation != want { + t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want) + } + if kept[0].Take.Access != "plex.access-services/media/movies" { + t.Errorf("the take names %q", kept[0].Take.Access) + } + + // The folder no longer waits (taken, or no module uses it): the open ask is taken back, and nothing is asked. + current = nil + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + if got := takeAsks(r); len(got) != 1 || got[0].State != askCancelled { + t.Fatalf("the ask about a folder that no longer waits: %+v", got) + } + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + if len(takeAsks(r)) != 1 { + t.Fatal("asked about a folder nothing waits on") + } + // A module joining a folder already taken is never gathered: its wait is not excused. + f := accessFacts(moviesAt) + if err := checkWait("radarr", "ace", keepWait("movies"), f); err == nil { + t.Fatal("a wait on a taken folder was excused, and would have been asked about") + } +} + +// Row 8: nothing is taken without the operator's warrant — an ask that ends without a choice, a warrant for another +// ask, or an act other than the one the option bound takes nothing; the warrant takes it once. +func TestAFolderIsTakenOnlyOnTheWarrant(t *testing.T) { + r, taken := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + a := takeAsks(r)[0] + if len(*taken) != 0 { + t.Fatal("taken before any warrant") + } + // A warrant for another asker's ask. + other := warrantOn(a, "let-the-mesh-keep-it", r.now) + other.Asker = "someone-else" + answerWith(t, r, other) + // The record changed after the ask: the act no longer the one bound. + changed := r.store[a.ID] + changed.Take = &folderTake{Node: "ace", Path: "/", Access: "radarr.access-movies", Modules: a.Take.Modules} + r.store[a.ID] = changed + answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now)) + if len(*taken) != 0 || !strings.HasPrefix(r.store[a.ID].Acted, "failed") { + t.Fatalf("a take whose record changed after the ask: %v, %q", *taken, r.store[a.ID].Acted) + } + + // Asked afresh, and the ask expires unanswered: nothing. + r2, taken2 := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + if err := r2.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + b := takeAsks(r2)[0] + expired := asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now} + answerWith(t, r2, expired) + if len(*taken2) != 0 { + t.Fatal("taken on an ask that expired") + } + + // The warrant: taken once, naming the access, the ask as the warrant, and who approved. + r3, taken3 := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"}) + }) + if err := r3.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + c := takeAsks(r3)[0] + w := warrantOn(c, "let-the-mesh-keep-it", r3.now) + answerWith(t, r3, w) + answerWith(t, r3, w) + if len(*taken3) != 1 || !strings.Contains((*taken3)[0], "ace "+moviesAt+" radarr.access-movies "+c.ID) || + !strings.Contains((*taken3)[0], "telegram") { + t.Fatalf("the warrant took %v", *taken3) + } + if !strings.HasPrefix(r3.store[c.ID].Acted, "done") { + t.Fatalf("the take was recorded %q", r3.store[c.ID].Acted) + } +} + +// The engine is asked only on a warrant, and the ask carries it: one with no warrant is never signed. +func TestATakeAskCarriesItsWarrantAndIsSigned(t *testing.T) { + _, key, _ := ed25519.GenerateKey(nil) + signer := keySigner{key} + ask := link.AccessActAsk{Node: "ace", Path: moviesAt, By: "the operator", Access: "radarr.access-services/media/movies"} + if _, err := link.SignTake(context.Background(), signer, ask); err == nil { + t.Fatal("a take with no warrant was signed") + } + ask.Warrant = "c0123456789abcdef" + body, err := link.SignTake(context.Background(), signer, ask) + if err != nil { + t.Fatal(err) + } + var signed link.SignedHandOver + if err := json.Unmarshal(body, &signed); err != nil { + t.Fatal(err) + } + if !ed25519.Verify(key.Public().(ed25519.PublicKey), append([]byte(link.AccessActContext), signed.Ask...), signed.Signature) { + t.Fatal("the take is not signed over its own context") + } + var got map[string]any + if err := json.Unmarshal(signed.Ask, &got); err != nil { + t.Fatal(err) + } + if got["act"] != "take" || got["warrant"] != "c0123456789abcdef" || got["access"] != ask.Access || + got["path"] != moviesAt || got["nonce"] == "" { + t.Fatalf("the ask carries %v", got) + } + // And the controller records it taken only on the engine's word. + refusing := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) { + return link.HandOverAnswer{Refused: "the disk that holds the folder is not there"}, nil + }) + if _, err := refusing(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil { + t.Fatal("a refused take was answered as done") + } + unreachable := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) { + return link.HandOverAnswer{}, errors.New("nothing on ace answers") + }) + if _, err := unreachable(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil { + t.Fatal("an unanswered take was answered as done") + } +} + +type keySigner struct{ key ed25519.PrivateKey } + +func (s keySigner) Sign(_ context.Context, b []byte) ([]byte, error) { + return ed25519.Sign(s.key, b), nil +} + +// The envelope carries `identities` only when composed — for an engine at link.IdentityContract — and an older +// engine's body is what it was. +func TestTheDeclarationSaysItsIdentitiesOnlyWhenComposed(t *testing.T) { + older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(older), "identities") { + t.Fatalf("an older engine's body names identities: %s", older) + } + newer, err := sendable{Resources: []map[string]any{{"id": "a"}}, Identities: []map[string]any{ + {"module": "radarr", "name": "mesh-radarr", "uid": 50000, "gid": 50000}}}.Body() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(newer), `"identities":[{"gid":50000,"module":"radarr","name":"mesh-radarr","uid":50000}]`) { + t.Fatalf("the body: %s", newer) + } + if link.IdentityContract != 5 { + t.Fatalf("the contract the engine states for ADR 0325 is 5, and this controller reads %d", link.IdentityContract) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 41c027e3..571493ce 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -77,6 +77,12 @@ 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.WaitsForDisk(), + What: w.What}) + } resources = append(resources, kept) } // **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 9929b2e0..68ec3959 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -487,6 +487,15 @@ func settingsCommand(ctx context.Context, args []string) error { if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil { return err } + // An owner a placement names is warned of (novox/hq ADR 0325 §1): accepted while registered modules still + // name one, refused once they have moved. + if shelf, err := inv.Catalogue(ctx); err == nil { + if m, known := shelf[positionals[0]]; known { + for _, w := range catalogue.PlacesOwnerWarnings(m, []catalogue.Layer{{From: where, Values: values}}) { + fmt.Println(w) + } + } + } fmt.Printf("%s on %s:\n", positionals[0], where) if len(added)+len(changed)+len(removed) == 0 { fmt.Println(" nothing changed") diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index 0357a4ce..85787028 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -44,6 +44,11 @@ func needsOperatorKey(module, node string) string { type operatorWaitFacts struct { manifests map[string]catalogue.Manifest given map[string]map[string]time.Time + // placed is, per "@", where each access of the module is on that machine, by id (novox/hq ADR + // 0325 §7); taken is, per machine, every folder the operator let the mesh keep there. A machine absent from + // taken could not be read, and no access wait of it is excused. + placed map[string]map[string]string + taken map[string]map[string]bool } // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. @@ -52,9 +57,17 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er 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 +93,39 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er return nil } +// checkAccessWait is nil when an access wait is excused (novox/hq ADR 0325 §7): the module's manifest declares the +// access, its folder is placed on that machine, and either the folder is absent and its disk is not there (§9), or +// it has not yet been taken under the operator's warrant (§8). That a folder that is there is not owned by root or a +// root group is the node-engine's to see: it states such an access unhealthy and never waiting (§12). Any other +// access wait is judged unhealthy, saying why: a module that gains an access to a folder already taken is given its +// entries without a wait. +func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.Wait, f operatorWaitFacts) error { + declared := false + for _, a := range m.Accesses { + declared = declared || a.ID == w.Access + } + if !declared { + return fmt.Errorf("says it waits for the access %s, which %s does not declare", w.Access, module) + } + path, placed := f.placed[module+"@"+machine][w.Access] + if !placed { + return fmt.Errorf("says it waits for the access %s, which is not placed on %s", w.Access, machine) + } + if w.Disk { + return nil + } + taken, readable := f.taken[machine] + if !readable { + return fmt.Errorf("says it waits for the access %s, and which folders are taken on %s could not be read", + w.Access, machine) + } + if taken[path] { + return fmt.Errorf("says it waits for the access %s, whose folder is taken on %s already: a module that gains "+ + "an access to a taken folder is given its entries without a wait", w.Access, machine) + } + return nil +} + // 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 +134,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 +197,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", w.Access, waitNames(w)) + case w.Access != "": + part = fmt.Sprintf("leave to keep the shared folder %s (%s, asked of you once for the folder", w.Access, + waitNames(w)) + } if w.Secret != "" { part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret) } @@ -180,6 +238,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", w.Access, node) + case w.Access != "": + act = fmt.Sprintf("let the mesh keep the shared folder %s on %s when you are asked", w.Access, node) } if act != "" && !seen[act] { seen[act] = true @@ -217,6 +280,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string if f.given == nil { f.given = map[string]map[string]time.Time{} } + if f.placed == nil { + f.placed = map[string]map[string]string{} + } + if f.taken == nil { + f.taken = map[string]map[string]bool{} + } if inv == nil { return } @@ -238,6 +307,20 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string } } } + // Where each access of it is placed there (ADR 0325 §7): read once per module and machine. + if m, known := f.manifests[module]; known && len(m.Accesses) > 0 { + if _, read := f.placed[module+"@"+machine]; !read { + if layers, err := inv.SettingsFor(ctx, machine, module); err == nil { + placed := map[string]string{} + for _, a := range m.Accesses { + if path, has := catalogue.AccessPathOn(m, layers, a.ID); has && a.ID != "" { + placed[a.ID] = path + } + } + f.placed[module+"@"+machine] = placed + } + } + } if _, read := f.given[module+"@"+machine]; read { continue } @@ -245,6 +328,15 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string f.given[module+"@"+machine] = given } } + if _, read := f.taken[machine]; !read { + if taken, err := inv.KeptFolders(ctx, machine); err == nil { + kept := map[string]bool{} + for path := range taken { + kept[path] = true + } + f.taken[machine] = kept + } + } } // waitingModules is every module with a waiting resource in a statement. diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 3a6a0a50..32a656ef 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -447,7 +447,7 @@ 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, Identities: composed.Identities, 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, @@ -975,9 +975,17 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + // Each module's identity and the folders taken here (novox/hq ADR 0325 §3, §8), to an engine that keeps folders. + keepsFolders, identities, keptFolders, err := folderFacts(ctx, inv, node, plan, choosing) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } return catalogue.Rendering{ ReadsHealth: readsHealth, JudgesRoot: judgesRoot, + KeepsFolders: keepsFolders, + Identities: identities, + KeptFolders: keptFolders, 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 +1018,57 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string return had && stated.Contract >= link.RootContract, nil } +// engineKeepsFolders says whether a machine's node-engine gives modules their identities and keeps shared folders +// (novox/hq ADR 0325), by its own newest statement, for the same reason as engineReadsHealth: an older engine parses +// strictly, and is sent none of it. +func engineKeepsFolders(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) { + stated, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false, err + } + return had && stated.Contract >= link.IdentityContract, nil +} + +// folderFacts is what a machine's composition needs of ADR 0325: whether its engine keeps folders, each identity of +// a module of it that declares one, and the folders taken there. **An identity is allocated only on the send path** +// (Allocating), as a port is: a question about what would be sent reads what was allocated, and a module that has +// none yet is composed without one. Nothing is read for an engine that does not keep folders. +func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution, + choosing Choosing) (bool, map[string]catalogue.ModuleIdentity, map[string]bool, error) { + keeps, err := engineKeepsFolders(ctx, inv, node) + if err != nil || !keeps { + return false, nil, nil, err + } + identities := map[string]catalogue.ModuleIdentity{} + for _, m := range plan.Modules { + if !catalogue.DeclaresIdentity(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 false, nil, nil, err + } + if had { + identities[m.Module] = id + } + } + taken, err := inv.KeptFolders(ctx, node) + if err != nil { + return false, nil, nil, err + } + kept := map[string]bool{} + for path := range taken { + kept[path] = true + } + return true, identities, kept, 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. diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 0c1e2116..20385898 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -18,6 +18,9 @@ import ( // make a machine look out of date for ever, or send something `plan` never showed. type sendable struct { Resources []map[string]any + // Identities is the declaration's `identities` (novox/hq ADR 0325 §3): composed only for an engine that keeps + // folders, and not sent at all when empty — an older engine refuses a key it does not know, whole. + Identities []map[string]any // 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 +113,9 @@ func (s sendable) Body() ([]byte, error) { if s.Generation > 0 { envelope["generation"] = s.Generation } + if len(s.Identities) > 0 { + envelope["identities"] = s.Identities + } // An empty declaration is deliberate here — the node owns nothing the mesh put there // (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness // is meant, so a truncated or mis-composed body is never mistaken for "own nothing". diff --git a/go.mod b/go.mod index cdf300d5..f7a73885 100644 --- a/go.mod +++ b/go.mod @@ -35,4 +35,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-20261012164304-51e97a8c1405 diff --git a/go.sum b/go.sum index e7436cbb..6647dedc 100644 --- a/go.sum +++ b/go.sum @@ -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-20261012164304-51e97a8c1405 h1:aC+TkJr9B6qYbeLNmzs4FtOm8/hIJw0ZrIpbU+bFWZE= +git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405/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= diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 3e5a42ca..eac4c380 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -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. diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8270aaf3..068deb45 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -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: { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 6099dfb0..023db5bd 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -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 diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 124de422..a3725b94 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -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", diff --git a/internal/builder/check.go b/internal/builder/check.go index 0a08d40c..25ce2060 100644 --- a/internal/builder/check.go +++ b/internal/builder/check.go @@ -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) diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 4cbdde6b..f7770830 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -246,6 +246,21 @@ 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 + + // KeepsFolders says this machine's node-engine gives modules their identities and keeps shared folders + // (novox/hq ADR 0325: its statement's contract is link.IdentityContract or later). To an older, strict engine none of + // it is sent — no `identities`, no container `identity`, no access `users` or `taken`, no owner derived from an + // identity — and what it runs is composed as before. + KeepsFolders bool + // Identities is each module's allocated identity, by module (ADR 0325 §3): the same on every machine. A module + // that declares `identity` and has none here is composed without one, as to an older engine. + Identities map[string]ModuleIdentity + // KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path. + KeptFolders map[string]bool + // AccountUIDs is the user number of each account a tool runner runs as here, where the mesh knows it: an access + // names the runner's account only with its number, which the node-engine requires (ADR 0325 §4). Nothing states + // it to the controller yet, so it is empty and no runner entry is sent. + AccountUIDs map[string]int } // RootField is a user resource's field saying the account must never become root without a person @@ -320,6 +335,9 @@ type Composed struct { // naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told // everything else. Push and plan say it; the merge gate refuses a change that adds one. Unplaced []string + // Identities is the declaration's `identities` (novox/hq ADR 0325 §3): [{module, name, uid, gid}] for each + // module composed with an identity. Empty to an engine that does not keep folders. + Identities []map[string]any } // ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of @@ -358,7 +376,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 +400,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 identities []ModuleIdentity + resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities) if err != nil { return Composed{}, err } @@ -394,8 +415,12 @@ 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} + if len(identities) > 0 { + composed.Identities = identitiesOf(identities) + } + return composed, nil } // BusMembershipID names the resource carrying a machine's membership for the new bus, and @@ -406,7 +431,7 @@ 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, identities *[]ModuleIdentity) ([]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 @@ -553,6 +578,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } resources := m.Resources + // The identity it runs as here (novox/hq ADR 0325 §3, §11): none to an older engine, nor without an allocation. + ident, hasIdent, err := identityFor(m, with) + if err != nil { + return nil, err + } + if hasIdent && identities != nil { + *identities = append(*identities, ident) + } + mountedByIdentity := dirsMountedByIdentity(m) + // 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 +1038,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, return nil, err } ownerInto(copied, placed) + // And its identity (novox/hq ADR 0325 §2, §3): a container's resolved, or taken out where none is composed; + // the owner of a directory its identity's containers mount. After the placement's owner: the identity wins. + if err := identityInto(copied, m, ident, hasIdent, mountedByIdentity); 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 @@ -1120,6 +1160,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string, out = append(out, copied) } + // **Who uses each of its shared folders** (novox/hq ADR 0325 §4, §5, §8): the access's `users` and `taken`, to + // an engine that keeps folders. + if with.KeepsFolders { + usersInto(out, owner, m, ident, hasIdent, r.toolsAccount(m, with), with) + } + // **What only the mesh knows, where this module asked for it.** The graph is the control // plane's; making a name resolve is the module's software. Emitted as ordinary files under // this module's name, so they are applied, reported and removed exactly as anything else diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 024d850a..c6e3717d 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1860,6 +1860,8 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) + // How each container's image is told who to run as, and what a module that says so is held to (novox/hq ADR 0325). + problems = append(problems, identityProblems(m)...) problems = append(problems, endpointNameProblems(m)...) problems = append(problems, RouteProblems(m)...) for _, port := range m.Guards { diff --git a/internal/catalogue/module_identity.go b/internal/catalogue/module_identity.go new file mode 100644 index 00000000..42c16158 --- /dev/null +++ b/internal/catalogue/module_identity.go @@ -0,0 +1,514 @@ +package catalogue + +import ( + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Every module is given an identity of its own, and a container that declares `identity` runs as it (novox/hq ADR +// 0325 §2, §3, §11). +// +// **The manifest says how its image is told who to run as, and nothing else.** A container resource may declare +// `identity`: `{"user": "env:PUID", "group": "env:PGID"}` for an image that reads its user and group from its +// environment, or `{"user": "run"}` for the container runtime's own user option. It is the image's interface, as its +// ports are. The mesh fills it in: the controller allocates the module one user and group number, mesh-wide, once, +// from a range of its own (IdentityLow–IdentityHigh), and the node-engine makes the account `mesh-` on each +// machine the module is assigned to and tells the image those numbers. +// +// **The range is 50000–59999, and a module's user and group numbers are one number.** Chosen above the numbers +// distributions give people (1000 up) and below the 60000s Debian keeps for its own daemons, and far from root's 0 +// and every root group a distribution ships (0, adm's 4, wheel's 10): no number in it is 0 or a root group's, so §11 +// holds by the range alone, and the store refuses a row outside it. Both numbers come from one sequence, so a +// module's uid and gid are equal; a number once given is never given again, even after the module is forgotten +// (the store keeps the row, inventory.AllocateIdentity). +// +// **What the controller sends, and only to an engine that reads it** (Rendering.KeepsFolders: its statement's +// contract is link.IdentityContract or later): +// +// - the declaration's `identities`: [{module, name, uid, gid}] for each module of the machine that declares +// `identity` on a container; +// - a container's `identity` resolved: {"mode": "env", "user_env", "group_env", "uid", "gid"} or {"mode": "run", +// "uid", "gid"}; +// - the owner of each of the module's directories a container declaring `identity` mounts: the identity's +// uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the +// identity, and owning it by the identity would lock its writer out; +// - an access's `users`: the module's identity where a container of it that declares `identity` mounts the +// access, and the tool runner's account where the module's tools run there and the account's number is known, +// each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access +// to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`, +// whether the operator let the mesh keep that folder on that machine (§8). +// +// To an older engine none of it is sent, and a container's `identity` is taken out: it runs as its image says, as +// before. A module that declares no `identity` is composed exactly as before, on any engine. + +// The range module identities are allocated from (novox/hq ADR 0325 §3, §11). The store holds the same bounds. +const ( + IdentityLow = 50000 + IdentityHigh = 59999 +) + +// AllocatableID says whether n is a number the mesh may give a module's identity: inside the range, so never 0 +// nor a root group's. +func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh } + +// IdentityField is a container resource's field saying how its image is told who to run as. +const IdentityField = "identity" + +// The two ways an image is told who to run as. +const ( + IdentityEnv = "env" + IdentityRun = "run" +) + +// IdentitySpec is a container's `identity` as its manifest declares it. +type IdentitySpec struct { + // Mode is IdentityEnv or IdentityRun. + Mode string + // UserEnv and GroupEnv are the environment variables an env image reads, such as PUID and PGID. + UserEnv, GroupEnv string +} + +// ModuleIdentity is one module's allocated identity: its numbers, the same on every machine. +type ModuleIdentity struct { + Module string + UID int + GID int +} + +// IdentityName is the account a module's identity is on every machine it is assigned to. +func IdentityName(module string) string { return "mesh-" + module } + +// Where an access's user comes from (novox/hq ADR 0325 §4, §5). +const ( + FromContainer = "container" + FromToolRunner = "tool-runner" +) + +// AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration +// AccessUser): the account, its user number, the use, and where it runs. The node-engine refuses a user with no +// number, so the tool runner's account is named only where its number is known (Rendering.AccountUIDs). +type AccessUser struct { + Identity string `json:"identity"` + UID int `json:"uid"` + Use string `json:"use"` + From string `json:"from"` +} + +var envName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +// ParseIdentity reads a container's `identity`, or refuses it in words. +func ParseIdentity(v any) (IdentitySpec, error) { + fields, ok := v.(map[string]any) + if !ok { + return IdentitySpec{}, fmt.Errorf("identity is {\"user\": \"env:\", \"group\": \"env:\"} or " + + "{\"user\": \"run\"}") + } + for k := range fields { + if k != "user" && k != "group" { + return IdentitySpec{}, fmt.Errorf("identity says %q, and it says only how the image is told its user "+ + "and group: \"user\" and \"group\", never an owner or a number (%s §1, §2)", k, ADR0325) + } + } + user, _ := fields["user"].(string) + group, hasGroup := fields["group"] + switch { + case user == IdentityRun: + if hasGroup { + return IdentitySpec{}, fmt.Errorf("identity {\"user\": \"run\"} takes no group: the runtime's user " + + "option carries both numbers") + } + return IdentitySpec{Mode: IdentityRun}, nil + case strings.HasPrefix(user, "env:"): + g, _ := group.(string) + if !strings.HasPrefix(g, "env:") { + return IdentitySpec{}, fmt.Errorf("identity names its user as %q and its group as %v; an image told "+ + "its user by its environment is told its group the same way, \"env:\"", user, group) + } + u, gr := strings.TrimPrefix(user, "env:"), strings.TrimPrefix(g, "env:") + if !envName.MatchString(u) || !envName.MatchString(gr) || u == gr { + return IdentitySpec{}, fmt.Errorf("identity names the variables %q and %q; each is an environment "+ + "variable's name, upper-case letters, digits and underscores, and the two differ", u, gr) + } + return IdentitySpec{Mode: IdentityEnv, UserEnv: u, GroupEnv: gr}, nil + } + return IdentitySpec{}, fmt.Errorf("identity says its user is %q; it is \"env:\" or \"run\"", user) +} + +// DeclaresIdentity says a module has a container that declares `identity`: it is given one (§3). +func DeclaresIdentity(m Manifest) bool { + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "container" { + if _, has := r[IdentityField]; has { + return true + } + } + } + return false +} + +// containerMounts is what a container of the manifest mounts, read from its volumes as written: the module's own +// directories by id, and its accesses by id — or, for a literal source, as "path:". +func containerMounts(r map[string]any) (dirs, accesses []string) { + volumes, _ := r["volumes"].([]any) + for _, v := range volumes { + s, _ := v.(string) + source := volumeSource(s) + if strings.HasPrefix(source, "/") { + accesses = append(accesses, "path:"+filepath.Clean(source)) + continue + } + for _, m := range dirRef.FindAllStringSubmatch(source, -1) { + dirs = append(dirs, m[1]) + } + for _, m := range accessRef.FindAllStringSubmatch(source, -1) { + accesses = append(accesses, m[1]) + } + } + return dirs, accesses +} + +// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so +// "${dir:config}:/config" is "${dir:config}". +func volumeSource(v string) string { + depth := 0 + for i, r := range v { + switch { + case r == '{' && i > 0 && v[i-1] == '$': + depth++ + case r == '}' && depth > 0: + depth-- + case r == ':' && depth == 0: + return v[:i] + } + } + return v +} + +// accessMode is the mode an access a container mounts was declared at, by id or by "path:"; "" when the +// manifest declares no such access. +func accessMode(m Manifest, ref string) string { + for _, a := range m.Accesses { + if (a.ID != "" && a.ID == ref) || (a.Path != "" && ref == "path:"+filepath.Clean(a.Path)) { + return a.At() + } + } + return "" +} + +// ADR0325 is how the module check names the decision its identity rules come from. +const ADR0325 = "novox/hq ADR 0325" + +// identityProblems is what the module check refuses of a manifest's identities (novox/hq ADR 0325 §1, §2, §3): +// +// - an `identity` on anything but a container, or one that is neither env: for both nor run; +// - **in a module that declares `identity` on a container** — it says it runs as its own identity, so it is held +// to the decision whole: a directory of it naming an owner (§1, §3), and a container of it that declares none +// and mounts one of the module's directories or a read-write access (§2). +// +// A module that declares no `identity` is warned of the last two (IdentityWarnings), never refused for them yet: +// every registered container module mounts its own directories without one, and refusing them would stop the +// catalogues composing before they can migrate. **This is the narrowest rule that breaks nothing registered**: no +// registered manifest declares `identity`, so no registered module is refused anything new. +func identityProblems(m Manifest) []string { + var problems []string + for _, r := range m.Resources { + v, has := r[IdentityField] + if !has { + continue + } + if fmt.Sprint(r["type"]) != "container" { + problems = append(problems, fmt.Sprintf("%s: %v is a %v and says identity; only a container says how its "+ + "image is told who to run as (%s §2)", m.Module, r["id"], r["type"], ADR0325)) + continue + } + if _, err := ParseIdentity(v); err != nil { + problems = append(problems, fmt.Sprintf("%s: the container %v: %v (%s §2)", m.Module, r["id"], err, ADR0325)) + } + } + if !DeclaresIdentity(m) { + return problems + } + return append(problems, identityRules(m)...) +} + +// IdentityWarnings is what the module check warns of a module that declares no `identity` (novox/hq ADR 0325): each +// thing the decision refuses once the module migrates, said now so the catalogues can. Refusing comes when no +// registered module still does any of it. Empty for a module that declares `identity`: it is refused these instead. +func IdentityWarnings(m Manifest) []string { + if DeclaresIdentity(m) { + return nil + } + var out []string + for _, p := range identityRules(m) { + out = append(out, p+"; accepted while registered modules still do it, refused once they have moved") + } + return out +} + +// identityRules is every place a manifest breaks what ADR 0325 §1–§3 refuse: a directory naming an owner, and a +// container declaring no identity that mounts the module's own directory or a read-write access. +func identityRules(m Manifest) []string { + var out []string + for _, r := range m.Resources { + switch fmt.Sprint(r["type"]) { + case "directory": + if owner, said := r["owner"]; said { + out = append(out, fmt.Sprintf("%s: the directory %v names its owner %v; a directory a module owns is "+ + "owned by the module's identity, and no manifest names an owner (%s §1, §3)", m.Module, r["id"], + owner, ADR0325)) + } + case "container": + if _, has := r[IdentityField]; has { + continue + } + dirs, accesses := containerMounts(r) + seen := map[string]bool{} + for _, d := range dirs { + if !seen[d] { + seen[d] = true + out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the module's "+ + "own directory %s; only a container that declares identity mounts its module's directories, "+ + "so what writes them is the module's identity (%s §2)", m.Module, r["id"], d, ADR0325)) + } + } + for _, a := range accesses { + if accessMode(m, a) == AccessReadWrite && !seen["access:"+a] { + seen["access:"+a] = true + out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the read-write "+ + "access %s; a container that runs as its image says is given no entry on a shared folder, so "+ + "it is refused write (%s §2)", m.Module, r["id"], strings.TrimPrefix(a, "path:"), ADR0325)) + } + } + } + } + return out +} + +// PlacesOwnerProblem refuses an owner a `places` setting names for a module that declares `identity` (novox/hq ADR +// 0325 §1, §3): its directories are owned by its identity. Nil for a module that declares none, whose placed owner +// is still applied, and warned of by PlacesOwnerWarnings. +func PlacesOwnerProblem(m Manifest, layers []Layer) error { + if !DeclaresIdentity(m) { + return nil + } + if owned := placedOwners(m, layers); len(owned) > 0 { + return fmt.Errorf("%s places %s with an owner, and declares identity: its directories are owned by its "+ + "identity, and no setting names an owner (%s §1, §3)", m.Module, strings.Join(owned, ", "), ADR0325) + } + return nil +} + +// PlacesOwnerWarnings is a warning for each directory a `places` setting gives an owner, for a module that declares +// no `identity` (novox/hq ADR 0325 §1): accepted while registered modules still do it, refused once they have moved. +func PlacesOwnerWarnings(m Manifest, layers []Layer) []string { + if DeclaresIdentity(m) { + return nil + } + var out []string + for _, id := range placedOwners(m, layers) { + out = append(out, fmt.Sprintf("warning: %s's setting places.%s.owner names an owner; a directory a module owns "+ + "is owned by the module's identity, and no setting names an owner (%s §1, §3). Accepted while "+ + "registered modules still do it, refused once they have moved", m.Module, id, ADR0325)) + } + return out +} + +func placedOwners(m Manifest, layers []Layer) []string { + placed, err := Places(m, layers) + if err != nil { + // empty-on-error: a placement Places refuses is refused by name where it is judged and composed; it names no owner here + return nil + } + var out []string + for id, p := range placed { + if p.Owner != "" { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +// resolvedIdentity is a container's `identity` as the node-engine is told it: the mode, the variables an env image +// reads, and the module's numbers. +func resolvedIdentity(spec IdentitySpec, id ModuleIdentity) map[string]any { + out := map[string]any{"mode": spec.Mode, "uid": id.UID, "gid": id.GID} + if spec.Mode == IdentityEnv { + out["user_env"], out["group_env"] = spec.UserEnv, spec.GroupEnv + } + return out +} + +// identityFor is the identity module m runs as here, and whether one is composed: a module that declares `identity`, +// to an engine that keeps folders, with an allocation the composition was given. A number outside the range is +// refused (§11), never sent. +func identityFor(m Manifest, with Rendering) (ModuleIdentity, bool, error) { + if !with.KeepsFolders || !DeclaresIdentity(m) { + return ModuleIdentity{}, false, nil + } + id, allocated := with.Identities[m.Module] + if !allocated { + return ModuleIdentity{}, false, nil + } + if !AllocatableID(id.UID) || !AllocatableID(id.GID) { + return ModuleIdentity{}, false, fmt.Errorf("%s's identity is %d:%d, outside %d–%d: no module identity is root "+ + "or a root group's, and none is sent (%s §11)", m.Module, id.UID, id.GID, IdentityLow, IdentityHigh, ADR0325) + } + id.Module = m.Module + return id, true, nil +} + +// identityInto composes one resource of module m for its identity: a container's `identity` resolved, or taken out +// where none is composed; and the owner of a directory one of its identity's containers mounts. +func identityInto(resource map[string]any, m Manifest, id ModuleIdentity, has bool, mountedByIdentity map[string]bool) error { + switch fmt.Sprint(resource["type"]) { + case "container": + v, declared := resource[IdentityField] + if !declared { + return nil + } + if !has { + delete(resource, IdentityField) + return nil + } + spec, err := ParseIdentity(v) + if err != nil { + return fmt.Errorf("%s: the container %v: %w", m.Module, resource["id"], err) + } + resource[IdentityField] = resolvedIdentity(spec, id) + case "directory": + if has && mountedByIdentity[fmt.Sprint(resource["id"])] { + resource["owner"] = fmt.Sprintf("%d:%d", id.UID, id.GID) + } + } + return nil +} + +// dirsMountedByIdentity is the ids of module m's directories one of its containers declaring `identity` mounts. +func dirsMountedByIdentity(m Manifest) map[string]bool { + out := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + if _, has := r[IdentityField]; !has { + continue + } + dirs, _ := containerMounts(r) + for _, d := range dirs { + out[d] = true + } + } + return out +} + +// identitiesOf is the declaration's `identities`, by module. +func identitiesOf(ids []ModuleIdentity) []map[string]any { + sort.Slice(ids, func(i, j int) bool { return ids[i].Module < ids[j].Module }) + out := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"module": id.Module, "name": IdentityName(id.Module), "uid": id.UID, + "gid": id.GID}) + } + return out +} + +// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no +// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration. +func (r Resolution) toolsAccount(m Manifest, with Rendering) string { + if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) { + return "" + } + loads := false + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + loads = true + } + } + if !loads { + return "" + } + if m.RunsAs != "" { + return m.RunsAs + } + return r.Account +} + +// usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity +// where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the +// module's tools run as it and its number is known, each at the access's use (§4: the engine keeps the widest). +func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string, + with Rendering) { + var containers []map[string]any + for _, res := range out { + if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module { + if _, declares := res[IdentityField]; declares && has { + containers = append(containers, res) + } + } + } + runnerUID, runnerKnown := with.AccountUIDs[account] + for _, res := range out { + if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module { + continue + } + path, _ := res["path"].(string) + mode, _ := res["mode"].(string) + if mode == "" { + mode = AccessRead + } + users := []AccessUser{} + for _, c := range containers { + if mounts(c, path) { + users = append(users, AccessUser{Identity: IdentityName(m.Module), UID: id.UID, Use: mode, From: FromContainer}) + break // one identity per module, whichever of its containers mounts it + } + } + if account != "" && runnerKnown && runnerUID > 0 { + users = append(users, AccessUser{Identity: account, UID: runnerUID, Use: mode, From: FromToolRunner}) + } + if len(users) > 0 { + res["users"] = users + } + res["taken"] = with.KeptFolders[filepath.Clean(path)] + } +} + +// mounts says whether a composed container mounts path, or something below it, from the machine. A path that merely +// begins with the same letters — /storage/media2 beside /storage/media — is another path. +func mounts(container map[string]any, path string) bool { + volumes, _ := container["volumes"].([]any) + path = filepath.Clean(path) + for _, v := range volumes { + s, ok := v.(string) + if !ok { + continue + } + source, _, _ := strings.Cut(s, ":") + if !strings.HasPrefix(source, "/") { + continue // a named volume: the runtime's, not the operator's path + } + source = filepath.Clean(source) + if source == path || strings.HasPrefix(source, path+"/") { + return true + } + } + return false +} + +// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the +// definition's default; false where neither says, or the module declares no such access. +func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) { + _, byID, err := accessesFor(m, layers) + if err != nil { + return "", false + } + path, has := byID[id] + if !has || path == "" { + return "", false + } + return filepath.Clean(path), true +} diff --git a/internal/catalogue/module_identity_test.go b/internal/catalogue/module_identity_test.go new file mode 100644 index 00000000..b17b8cee --- /dev/null +++ b/internal/catalogue/module_identity_test.go @@ -0,0 +1,339 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + + "github.com/novox/mesh-host/validate" +) + +// Defends novox/hq ADR 0325 on the controller's side, row by row of its "How it is checked" table: the module check +// (rows 1, 2), the identity composed (row 3), the tool runner's entry (row 4), no identity root (row 11) — and none +// of it sent to a node-engine older than the contract, which would refuse the whole declaration. + +const identityImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111" + +// aLibrarian files films: a container told its identity by PUID and PGID that mounts its config and the movies it +// writes; a sidecar that declares none and reads the music; a directory only its tools write; and tools served by the +// machine's runtime. +func aLibrarian(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "librarian", "tools/index.js") + m.Accesses = []Access{ + {ID: "movies", Path: "/storage/media/movies", Mode: AccessReadWrite}, + {ID: "music", Path: "/storage/media/music", Mode: AccessRead}, + } + m.Resources = []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "notes", "type": "directory", "mode": "0700"}, + {"id": "app", "type": "container", "name": "librarian", "image": identityImage, + "identity": map[string]any{"user": "env:PUID", "group": "env:PGID"}, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}, + {"id": "sidecar", "type": "container", "name": "librarian-sidecar", "image": identityImage, + "volumes": []any{"${access:music}:/music:ro"}}, + } + return m +} + +// aPlayer reads the movies the librarian writes, as its own identity through the runtime's user option, and has +// tools of its own that read them too. +func aPlayer(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "player", "tools/index.js") + m.Accesses = []Access{{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}} + m.Resources = []map[string]any{ + {"id": "app", "type": "container", "name": "player", "image": identityImage, + "identity": map[string]any{"user": "run"}, "volumes": []any{"${access:movies}:/movies:ro"}}, + } + return m +} + +func keepingFolders(ids map[string]ModuleIdentity, kept ...string) Rendering { + with := Rendering{KeepsFolders: true, Identities: ids, KeptFolders: map[string]bool{}} + for _, k := range kept { + with.KeptFolders[k] = true + } + return with +} + +func composeFolders(t *testing.T, r Resolution, with Rendering) Composed { + t.Helper() + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + composed, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + return composed +} + +var identities = map[string]ModuleIdentity{"librarian": {UID: 50001, GID: 50001}, "player": {UID: 50002, GID: 50002}} + +// Row 3: the identity composed — the same on every machine, the container told it, the module's directories its +// containers mount owned by it, and the declaration's identities naming the account. +func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) { + var seen []string + for _, node := range []string{"ace", "novox"} { + r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + composed := composeFolders(t, r, keepingFolders(identities)) + got := byID(composed.Resources) + + app := got["librarian.app"] + ident, _ := app[IdentityField].(map[string]any) + want := map[string]any{"mode": "env", "user_env": "PUID", "group_env": "PGID", "uid": 50001, "gid": 50001} + if fmt.Sprint(ident) != fmt.Sprint(want) { + t.Errorf("%s: the container is told %v; want %v", node, ident, want) + } + player, _ := got["player.app"][IdentityField].(map[string]any) + if fmt.Sprint(player) != fmt.Sprint(map[string]any{"mode": "run", "uid": 50002, "gid": 50002}) { + t.Errorf("%s: the player is told %v", node, player) + } + if _, has := got["librarian.sidecar"][IdentityField]; has { + t.Errorf("%s: a container declaring no identity was given one", node) + } + if owner := got["librarian.config"]["owner"]; owner != "50001:50001" { + t.Errorf("%s: the directory its identity's container mounts is owned by %v", node, owner) + } + if owner, has := got["librarian.notes"]["owner"]; has { + t.Errorf("%s: a directory no identity container mounts was given the owner %v: what writes it is not "+ + "the identity", node, owner) + } + raw, _ := json.Marshal(composed.Identities) + seen = append(seen, string(raw)) + if !strings.Contains(string(raw), `"name":"mesh-librarian"`) || !strings.Contains(string(raw), `"uid":50001`) { + t.Errorf("%s: the declaration's identities are %s", node, raw) + } + } + // What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused. + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + with := keepingFolders(identities, "/storage/media/movies") + with.AccountUIDs = map[string]int{"jochen": 1000} + composed := composeFolders(t, r, with) + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources, + "identities": composed.Identities}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("the pinned node-engine refuses it: %v", problems) + } + if seen[0] != seen[1] { + t.Fatalf("the identities differ between machines: %s and %s", seen[0], seen[1]) + } +} + +// Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring +// identity mounts it, never a container declaring none — and the tool runner's account where its number is known, +// each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so +// the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken. +func TestAnAccessNamesItsUsers(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + with := keepingFolders(identities, "/storage/media/movies") + with.AccountUIDs = map[string]int{"jochen": 1000} + got := byID(composeFolders(t, r, with).Resources) + + users := func(id string) string { + raw, _ := json.Marshal(got[id]["users"]) + return string(raw) + } + movies := "librarian." + AccessID("/storage/media/movies") + if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` + + `{"identity":"jochen","uid":1000,"use":"read-write","from":"tool-runner"}]`; users(movies) != want { + t.Errorf("the librarian's movies name %s; want %s", users(movies), want) + } + played := "player." + AccessID("/storage/media/movies") + if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` + + `{"identity":"jochen","uid":1000,"use":"read","from":"tool-runner"}]`; users(played) != want { + t.Errorf("the player's movies name %s; want %s", users(played), want) + } + // The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools. + music := "librarian." + AccessID("/storage/media/music") + if want := `[{"identity":"jochen","uid":1000,"use":"read","from":"tool-runner"}]`; users(music) != want { + t.Errorf("the music names %s; want only the tool runner's read", users(music)) + } + if got[movies]["taken"] != true || got[music]["taken"] != false { + t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"]) + } + + // The runner's number unknown: no entry for it, never one the engine refuses for uid 0. + got = byID(composeFolders(t, r, keepingFolders(identities)).Resources) + if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"}]`; users(movies) != want { + t.Errorf("with no number for the runner's account, the movies name %s; want %s", users(movies), want) + } + if _, has := got[music]["users"]; has { + t.Errorf("an access nobody with a known number uses names users: %s", users(music)) + } +} + +// The contract: to an engine that does not state link.IdentityContract nothing of ADR 0325 is sent — no identities, no +// container identity, no users, no taken, no owner from an identity — and what is sent is what the pinned node-engine +// accepts, as before. +func TestNothingOfTheIdentityIsSentToAnOlderEngine(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} + composed := composeFolders(t, r, Rendering{Identities: identities}) + if len(composed.Identities) > 0 { + t.Fatalf("identities were composed for an older engine: %v", composed.Identities) + } + for _, res := range composed.Resources { + for _, field := range []string{IdentityField, "users", "taken"} { + if _, has := res[field]; has { + t.Errorf("%v carries %s for an older engine", res["id"], field) + } + } + if res["id"] == "librarian.config" && res["owner"] != nil { + t.Errorf("the directory was given the owner %v for an older engine", res["owner"]) + } + } + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("the pinned node-engine refuses it: %v", problems) + } + // An engine that keeps folders, with no allocation yet (a question asked before the first send): composed + // without an identity, never with a made-up one. + none := composeFolders(t, r, keepingFolders(nil)) + if len(none.Identities) > 0 || byID(none.Resources)["librarian.app"][IdentityField] != nil { + t.Fatal("an identity was composed with no allocation") + } +} + +// Row 11: no identity outside the range is ever sent — 0 above all — and the composition says why. +func TestNoModuleIdentityIsRoot(t *testing.T) { + for _, n := range []int{0, 4, 10, 1000, IdentityLow - 1, IdentityHigh + 1} { + if AllocatableID(n) { + t.Errorf("%d is allocatable", n) + } + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}} + with := keepingFolders(map[string]ModuleIdentity{"librarian": {UID: n, GID: n}}) + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§11") { + t.Errorf("an identity of %d composed: %v", n, err) + } + } + for _, n := range []int{IdentityLow, IdentityHigh} { + if !AllocatableID(n) { + t.Errorf("%d is not allocatable", n) + } + } +} + +func manifestJSON(t *testing.T, m map[string]any) []byte { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +func aContainerModule(name string, resources ...map[string]any) map[string]any { + return map[string]any{"module": name, "version": "1", "capabilities": []any{"container-runtime"}, + "accesses": []any{map[string]any{"id": "movies", "path": "/storage/media/movies", "mode": "read-write"}}, + "resources": resources} +} + +// Rows 1 and 2: the module check accepts identity as env: or run and refuses every other shape and an owner in +// it; a module that declares identity is refused a directory naming its owner and a container declaring none that +// mounts its own directory or a read-write access; a directory's mode is accepted. +func TestTheModuleCheckHoldsAnIdentityModuleToTheDecision(t *testing.T) { + ok := func(identity any) map[string]any { + return map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage, + "identity": identity, "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + } + config := map[string]any{"id": "config", "type": "directory", "mode": "0750"} + for _, identity := range []any{ + map[string]any{"user": "env:PUID", "group": "env:PGID"}, + map[string]any{"user": "run"}, + } { + if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(identity)))); err != nil { + t.Errorf("identity %v refused: %v", identity, err) + } + } + for _, c := range []struct { + identity any + want string + }{ + {map[string]any{"user": "1000", "group": "1000"}, `"env:" or "run"`}, + {map[string]any{"user": "env:PUID"}, "told its group the same way"}, + {map[string]any{"user": "run", "group": "env:PGID"}, "takes no group"}, + {map[string]any{"user": "env:puid", "group": "env:PGID"}, "environment variable's name"}, + {map[string]any{"user": "env:PUID", "group": "env:PGID", "owner": "1000:1000"}, "never an owner or a number"}, + {"run", "identity is"}, + } { + _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(c.identity)))) + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "ADR 0325") { + t.Errorf("identity %v: %v, want it refused saying %q", c.identity, err, c.want) + } + } + // An identity on anything but a container. + onAFile := map[string]any{"id": "f", "type": "directory", "mode": "0750", "identity": map[string]any{"user": "run"}} + if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", onAFile))); err == nil { + t.Error("an identity on a directory was accepted") + } + + // Held to the decision whole once it declares identity (§1, §2, §3). + owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"} + bare := map[string]any{"id": "helper", "type": "container", "name": "helper", "image": identityImage, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + _, err := ParseManifest(manifestJSON(t, aContainerModule("app", owned, ok(map[string]any{"user": "run"}), bare))) + for _, want := range []string{"names its owner 1000:1000", "declares no identity and mounts the module's own directory config", + "declares no identity and mounts the read-write access movies"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("an identity module: %v, want it refused saying %q", err, want) + } + } + // A setting's owner too. + settingOwned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "${setting:puid}:${setting:pgid}"} + identityModule := aContainerModule("app", settingOwned, ok(map[string]any{"user": "run"})) + identityModule["settings"] = map[string]any{"puid": map[string]any{"default": "1000"}, "pgid": map[string]any{"default": "1000"}} + if _, err := ParseManifest(manifestJSON(t, identityModule)); err == nil || !strings.Contains(err.Error(), "names its owner") { + t.Errorf("a setting's owner in an identity module: %v", err) + } +} + +// Rows 1 and 2 for what is registered today: a module that declares no identity is warned, never refused — a +// directory naming its owner, a container mounting its own directory or a read-write access — and each warning +// names ADR 0325. A placement's owner is refused for an identity module and warned of for any other. +func TestAModuleWithoutIdentityIsWarnedNotRefused(t *testing.T) { + owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"} + bare := map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage, + "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}} + m, err := ParseManifest(manifestJSON(t, aContainerModule("legacy", owned, bare))) + if err != nil { + t.Fatalf("a registered shape was refused: %v", err) + } + warnings := IdentityWarnings(m) + sort.Strings(warnings) + if len(warnings) != 3 { + t.Fatalf("warned %d times: %q", len(warnings), warnings) + } + for _, w := range warnings { + if !strings.Contains(w, "ADR 0325") || !strings.Contains(w, "refused once they have moved") { + t.Errorf("a warning that does not name ADR 0325 or say refusal comes later: %q", w) + } + } + + placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/srv/legacy", "owner": "1000:1000"}}}}} + if err := PlacesOwnerProblem(m, placed); err != nil { + t.Errorf("a placement's owner refused for a module without identity: %v", err) + } + if w := PlacesOwnerWarnings(m, placed); len(w) != 1 || !strings.Contains(w[0], "places.config.owner") { + t.Errorf("a placement's owner warned %q", w) + } + withIdentity := aLibrarian(t) + placedLibrarian := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/srv/librarian", "owner": "1000:1000"}}}}} + if err := PlacesOwnerProblem(withIdentity, placedLibrarian); err == nil || !strings.Contains(err.Error(), "ADR 0325") { + t.Errorf("a placement's owner for an identity module: %v", err) + } + if len(IdentityWarnings(withIdentity)) != 0 { + t.Error("an identity module was warned rather than refused") + } +} diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index 400c5b3f..9425e8bc 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -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 } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 534c7424..f657ba4c 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -509,6 +509,18 @@ 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 { if err := settingsHoldOneLine(m.Module, layers); err != nil { return err } diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 6ce3740c..1d586504 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -958,7 +958,28 @@ 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 || 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 runs as its own identity owns its directories by it: no setting names their owner (novox/hq ADR + // 0325 §1, §3). A module that does not yet keeps its placed owner, warned of where it is set. + if err := catalogue.PlacesOwnerProblem(m, layers); err != nil { + 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 +990,29 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, return nil } +// 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. False when no build of the module with a manifest is kept. +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, '') = '' or b.ref = '' or 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. diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 1ee5a829..d89a75ee 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -38,13 +38,17 @@ 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"` + What string `json:"what"` } // NodeHealth is a machine's newest statement, as kept. diff --git a/internal/inventory/identities.go b/internal/inventory/identities.go new file mode 100644 index 00000000..f53a607a --- /dev/null +++ b/internal/inventory/identities.go @@ -0,0 +1,120 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Module identities and taken folders (novox/hq ADR 0325 §3, §8, §11; migration 0096). + +// AllocateIdentity is module's identity, allocated now when it has none: one number from the range of its own, +// mesh-wide, kept for ever. Two controllers allocating at once agree: the second's insert finds the first's row. +func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) { + if strings.TrimSpace(module) == "" { + return catalogue.ModuleIdentity{}, errors.New("an identity is allocated for a module, and none was named") + } + if id, had, err := i.IdentityOf(ctx, module); err != nil || had { + return id, err + } + // The number is drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never + // a number given twice. + _, err := i.store.Pool().Exec(ctx, + `insert into module_identity (module, uid, gid) + select $1, n, n from (select nextval('module_identity_number')::integer as n) drawn + on conflict (module) do nothing`, module) + if err != nil { + return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given an identity: %w", module, err) + } + id, had, err := i.IdentityOf(ctx, module) + if err == nil && !had { + err = fmt.Errorf("%s was given an identity and it cannot be read back", module) + } + return id, err +} + +// IdentityOf is module's identity, and false when it was never allocated one. +func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) { + id := catalogue.ModuleIdentity{Module: module} + err := i.store.Pool().QueryRow(ctx, `select uid, gid from module_identity where module = $1`, module). + Scan(&id.UID, &id.GID) + if errors.Is(err, pgx.ErrNoRows) { + return catalogue.ModuleIdentity{}, false, nil + } + if err != nil { + return catalogue.ModuleIdentity{}, false, err + } + return id, true, nil +} + +// Identities is every module's identity, by module. +func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) { + rows, err := i.store.Pool().Query(ctx, `select module, uid, gid from module_identity`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]catalogue.ModuleIdentity{} + for rows.Next() { + var id catalogue.ModuleIdentity + if err := rows.Scan(&id.Module, &id.UID, &id.GID); err != nil { + return nil, err + } + out[id.Module] = id + } + return out, rows.Err() +} + +// TakenFolder is one folder the operator let the mesh keep on a machine (ADR 0325 §8). +type TakenFolder struct { + Path string + By string + Ask string + Warrant string + Said string +} + +// KeptFolders is every folder taken on a machine, by its clean path. +func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) { + rows, err := i.store.Pool().Query(ctx, + `select k.path, k.taken_by, k.ask, k.warrant, k.said from kept_folder k join node n on n.id = k.node + where n.name = $1`, nodeName) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]TakenFolder{} + for rows.Next() { + var t TakenFolder + if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said); err != nil { + return nil, err + } + out[t.Path] = t + } + return out, rows.Err() +} + +// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once: +// a second for the same folder keeps the first. +func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error { + switch { + case !strings.HasPrefix(t.Path, "/"): + return fmt.Errorf("a taken folder is an absolute path, not %q", t.Path) + case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "": + return errors.New("a folder is taken only on the operator's warrant, and this names none") + } + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `insert into kept_folder (node, path, taken_by, ask, warrant, said) values ($1, $2, $3, $4, $5, $6) + on conflict (node, path) do nothing`, node.ID, filepath.Clean(t.Path), t.By, t.Ask, t.Warrant, t.Said) + return err +} diff --git a/internal/inventory/identities_test.go b/internal/inventory/identities_test.go new file mode 100644 index 00000000..a50c67af --- /dev/null +++ b/internal/inventory/identities_test.go @@ -0,0 +1,158 @@ +package inventory + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0325 §3, §11: a module gets one allocation, the same whoever asks and however often, from the range +// of its own; no allocation is 0 or a root group's; a number once given is never given again. +func TestAModuleIsGivenOneIdentityOnceAndNeverRoot(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + radarr, err := inv.AllocateIdentity(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + again, err := inv.AllocateIdentity(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + if radarr != again { + t.Fatalf("radarr was given %+v, then %+v: one allocation per module", radarr, again) + } + plex, err := inv.AllocateIdentity(ctx, "plex") + if err != nil { + t.Fatal(err) + } + for _, id := range []catalogue.ModuleIdentity{radarr, plex} { + if id.UID == 0 || id.GID == 0 || !catalogue.AllocatableID(id.UID) || !catalogue.AllocatableID(id.GID) || + id.UID != id.GID { + t.Errorf("%s was given %d:%d: within %d–%d, never 0, uid and gid one number", id.Module, id.UID, id.GID, + catalogue.IdentityLow, catalogue.IdentityHigh) + } + } + if radarr.UID == plex.UID { + t.Fatalf("radarr and plex share %d", plex.UID) + } + + // Read back as composition reads it, from another connection's view: the same numbers (stable across restarts + // and machines, since every machine's composition reads this one row). + all, err := inv.Identities(ctx) + if err != nil { + t.Fatal(err) + } + if all["radarr"].UID != radarr.UID || all["plex"].UID != plex.UID { + t.Fatalf("the identities read back are %+v", all) + } + + // A module forgotten keeps its row, and the next module is given a new number: never one given before. + if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil { + t.Fatal(err) + } + sonarr, err := inv.AllocateIdentity(ctx, "sonarr") + if err != nil { + t.Fatal(err) + } + if sonarr.UID == plex.UID || sonarr.UID == radarr.UID { + t.Fatalf("sonarr was given %d, a number given before", sonarr.UID) + } + + // The store itself refuses a number outside the range, root's above all: no write by hand makes one root. + for _, n := range []int{0, 10, 1000, 49999, 60000} { + if _, err := inv.store.Pool().Exec(ctx, + `insert into module_identity (module, uid, gid) values ($1, $2, $2)`, "byhand", n); err == nil { + t.Errorf("the store took %d as an identity", n) + } + } +} + +// novox/hq ADR 0325 §8: a folder taken is recorded once per machine, under a warrant, and read back by its path. +func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "ace"); err != nil { + t.Fatal(err) + } + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies/", By: "the operator"}); err == nil { + t.Fatal("a folder was recorded taken with no warrant") + } + first := TakenFolder{Path: "/storage/media/movies/", By: "the operator", Ask: "c01", Warrant: "c01", Said: "started"} + if err := inv.RecordTaken(ctx, "ace", first); err != nil { + t.Fatal(err) + } + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "someone else", Ask: "c02", + Warrant: "c02"}); err != nil { + t.Fatal(err) + } + kept, err := inv.KeptFolders(ctx, "ace") + if err != nil { + t.Fatal(err) + } + got, has := kept["/storage/media/movies"] + if !has || got.Ask != "c01" || got.By != "the operator" { + t.Fatalf("the taken folder reads %+v (%v): the first take, by its clean path", got, has) + } +} + +// 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") + } +} diff --git a/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql new file mode 100644 index 00000000..4117383a --- /dev/null +++ b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql @@ -0,0 +1,40 @@ +-- Every module is given an identity of its own, and the mesh takes a shared folder once, on the operator's word +-- (novox/hq ADR 0325 §3, §8, §11). +-- +-- **One number per module, mesh-wide, allocated once and never again.** The node-engine makes the account +-- mesh- on each machine the module is assigned to, with these numbers, so a container that declares +-- `identity` runs as the same uid and gid everywhere and a folder's entries mean the same on every machine. The +-- numbers come from a sequence of their own, 50000–59999 (catalogue.IdentityLow and IdentityHigh): above the +-- numbers distributions give people, below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a +-- root group's (0, 4, 10). The checks refuse a row outside the range, so no write, by hand or by a later bug, can make +-- an identity root (§11). uid and gid are one number. A sequence never hands a number back, and a row is kept after +-- its module is forgotten, so a number once given is never given to another module: a file left owned by a removed +-- module's identity is never inherited by a new one. +create sequence module_identity_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle; + +create table module_identity ( + module text primary key, + uid integer not null unique check (uid between 50000 and 59999), + gid integer not null unique check (gid between 50000 and 59999), + allocated timestamptz not null default now(), + check (uid = gid) +); + +-- **A folder taken, per machine** (§8). The first time a placed folder that already holds data is to be kept, the +-- operator is asked once for that folder on that machine; on the warrant the node-engine adds the entries to +-- everything there, and the folder is taken: kept from then on without asking, a module that gains the access +-- added with no wait. The row is the record of that act — who approved it, under which warrant — and what tells a +-- later module's access that it joins a taken folder (`taken` on the access). The path is the folder as placed, +-- clean and absolute. +create table kept_folder ( + node uuid not null references node (id) on delete cascade, + path text not null check (path like '/%'), + taken_at timestamptz not null default now(), + -- Who approved it, as the hand-act log says it, and the ask and warrant it was taken under. + taken_by text not null, + ask text not null, + warrant text not null, + -- What the node-engine answered when it started the take. + said text not null default '', + primary key (node, path) +); diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 63349ece..c4b4bf89 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -8,6 +8,7 @@ package link import ( "encoding/base64" + "encoding/json" "github.com/novox/mesh-host/rootsearch" "strconv" "strings" @@ -436,6 +437,14 @@ 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 + // 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 +589,36 @@ 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"` +} + +// WaitsForDisk says an access wait is for a disk that is not there (ADR 0325 §9) rather than for the operator's word +// to keep a folder: read from the engine's words, the one thing its wait says of which it is. +func (w AccessWait) WaitsForDisk() bool { + return strings.HasPrefix(strings.TrimSpace(w.What), "mount the disk") +} + +// KeptEntry is one user of a kept folder as the engine's listing says it (ADR 0325 §4), with a note beside the tool +// runner's entry. +type KeptEntry struct { + Identity string `json:"identity"` + UID int `json:"uid"` + Use string `json:"use"` + From string `json:"from"` + Note string `json:"note,omitempty"` +} + // ResourceHealth is one long-running resource's state. type ResourceHealth struct { Module string `json:"module"` @@ -606,6 +645,11 @@ 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; Entries the kept folder's listing. From an engine at IdentityContract only. + AccessWaits []AccessWait `json:"access-waits,omitempty"` + Act json.RawMessage `json:"act,omitempty"` + Entries []KeptEntry `json:"entries,omitempty"` } // HealthSaid is the health event's body: the machine and its statement. The machine is read from the diff --git a/internal/link/take.go b/internal/link/take.go new file mode 100644 index 00000000..9462503f --- /dev/null +++ b/internal/link/take.go @@ -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: , 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"}) +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index a37baf3b..695ae040 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -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 51e97a8c14058fd58e108a045b5d94744f23a6ee 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: diff --git a/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured b/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured index 01112d50..e23ef454 100644 --- a/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured +++ b/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured @@ -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: diff --git a/testdata/store-tests b/testdata/store-tests index c510f6d9..9b5358a5 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -29,3 +29,8 @@ ./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb ./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave ./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave +# novox/hq ADR 0325 §3, §8, §11 and issue 575: identities allocated once and never root, a folder taken under a +# warrant, a placement judged against the build that brings its access. +./internal/inventory TestAModuleIsGivenOneIdentityOnceAndNeverRoot +./internal/inventory TestAFolderIsTakenOnceUnderAWarrant +./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 50db892e..4f4ba924 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -456,14 +456,56 @@ 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 chown, chmod or remove it. The host confirms it +// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it +// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the +// filesystem the data was placed on (§9). 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 are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each + // module identity whose container mounts it, and the tool runner's account standing for every module whose + // tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several + // accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a + // controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept. + Users []AccessUser `json:"users,omitempty"` + // Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is + // kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty. + 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. +type AccessUser struct { + Identity string `json:"identity"` + UID int `json:"uid"` + 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 +524,41 @@ 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: + // 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. + 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 +1234,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 +1388,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 +1539,53 @@ 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 0325 §3): an account + // and a group `mesh-` 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 +} + +// Identity is one module's identity, as the controller allocated it. +type Identity struct { + Module string `json:"module"` + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` +} + +// 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) []string { + var problems []string + names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{} + 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, 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 0325 §11); 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)) + } + if other, used := gids[id.GID]; used { + problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other)) + } + names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name + } + return problems } // LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the @@ -1561,6 +1755,9 @@ 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). **An older host refuses this key**; a + // controller sends it only to a host whose health says link.IdentityContract. + Identities []Identity `json:"identities,omitempty"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1578,8 +1775,9 @@ 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} + problems := checkIdentities(env.Identities) 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 +1930,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. diff --git a/vendor/modules.txt b/vendor/modules.txt index bb6a448e..4231b52e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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-20261012164304-51e97a8c1405 ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -135,4 +135,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-20261012164304-51e97a8c1405