Merge pull request 'The seat node-files: write, copy, move and delete a file on a node through the mesh, with trash and owned paths (issue 900, ADR 0394)' (#360) from feat/900-node-files into main

This commit was merged in pull request #360.
This commit is contained in:
2026-10-16 14:25:00 +00:00
6 changed files with 269 additions and 2 deletions
+147
View File
@@ -0,0 +1,147 @@
package catalogue
import (
"encoding/json"
"fmt"
"path"
"strings"
)
// A node's files, read and managed (novox/hq ADR 0394, issue 900; superseding ADR 0310; hq to-be 41 WP9).
//
// **One seat for every file on a node.** The holder runs as the tool runner's account, the node's operator
// account (ADR 0394 §2), and reaches further through `sudo -n` where that account's rights do not. It refuses by
// name the places a secret is kept (reads only, ADR 0394 §7) and the paths a module owns, unless a write names a reason (`override`).
// node-reads stays beside it until mesh-files is assigned on every node (ADR 0394 §9); it goes in the next
// delivery.
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
const FilesSeat = "node-files"
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
const FilesOwnedPaths = "MESH_OWNED_PATHS"
const (
filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
"secret is kept, or a known credential file, is refused by name"
overrideWord = "a reason, to change a path a module owns anyway (optional)"
)
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
// with their descriptions restated for the operator's account, and the writes.
func filesVerbs() []Verb {
var verbs []Verb
for _, v := range readsVerbs() {
v.Description = strings.Replace(v.Description,
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
"", 1)
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
verbs = append(verbs, v)
}
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return Verb{Name: name, Description: description + " Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
}
return append(verbs,
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
map[string]string{"path": "an absolute path", "content": "the text to write",
"mode": "the file's mode, such as 0644 (optional)"},
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
"kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path"},
[]string{"from", "to"}, nil, "mv"),
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
write("mkdir", "Make a directory.",
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
write("chmod", "Change the mode of a file or directory.",
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
"recursive": "\"true\": also everything under a directory"},
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
write("chown", "Change the owner and group of a file or directory.",
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
)
}
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
// runner's account, the node's operator account, so it declares no runs-as.
func FilesHolderProblems(m Manifest) []string {
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
return nil
}
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
func (r Resolution) ownedPaths() map[string]string {
owned := map[string]string{}
for _, m := range r.Modules {
for _, p := range sortedKeys(declaredPaths(m)) {
owned[p] = m.Module + " (its own tools)"
}
}
for _, s := range Seats() {
for _, sp := range s.Paths {
for _, m := range r.Modules {
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) {
owned[sp.Path] = m.Module + " (" + s.Name + ")"
}
}
}
}
return owned
}
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
// paths modules own here.
func (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
if !m.ClaimsSeat(FilesSeat) {
return words, nil
}
places, err := r.secretPlaces(with)
if err != nil {
return nil, err
}
places2, err := json.Marshal(places)
if err != nil {
return nil, err
}
owned, err := json.Marshal(r.ownedPaths())
if err != nil {
return nil, err
}
if words == nil {
words = map[string]string{}
}
for _, w := range []string{ReadsSecretPlaces, FilesOwnedPaths} {
if _, has := words[w]; has {
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
"say (novox/hq ADR 0394 §5)", m.Module, w)
}
}
words[ReadsSecretPlaces] = string(places2)
words[FilesOwnedPaths] = string(owned)
return words, nil
}
+111
View File
@@ -0,0 +1,111 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace.
func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
s, ok := SeatNamed(FilesSeat)
if !ok {
t.Fatalf("the mesh defines no seat %s", FilesSeat)
}
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") {
t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision)
}
have := map[string]bool{}
replaced := map[string]string{}
for _, v := range s.Serves {
have[v.Name] = true
if v.Optional {
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
}
if len(v.Replaces) == 0 {
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
}
for _, c := range v.Replaces {
replaced[c] = v.Name
}
}
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
"delete", "mkdir", "chmod", "chown", "trash", "restore"} {
if !have[name] {
t.Errorf("%s serves no verb %s", FilesSeat, name)
}
}
for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move",
"rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} {
if replaced[command] != verb {
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb)
}
}
}
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
t.Errorf("a holder declaring runs-as was not refused: %v", p)
}
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
t.Errorf("a holder declaring none was refused: %v", p)
}
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
t.Errorf("a non-holder was judged: %v", p)
}
}
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}},
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
app := Manifest{Module: "app", Version: "1",
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}}
words, err := r.filesWords(files, nil, Rendering{})
if err != nil {
t.Fatal(err)
}
var places []string
if err := json.Unmarshal([]byte(words[ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
t.Errorf("no secret places: %v %v", err, words)
}
var owned map[string]string
if err := json.Unmarshal([]byte(words[FilesOwnedPaths]), &owned); err != nil {
t.Fatalf("no owned paths: %v (%v)", err, words)
}
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
}
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
}
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
t.Error("a module not holding node-files is told the owned paths")
}
}
// issue 900: restore, like every write verb, takes a reason and an override.
func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
s, _ := SeatNamed(FilesSeat)
for _, v := range s.Serves {
if v.Name != "restore" {
continue
}
props, _ := v.Input["properties"].(map[string]any)
for _, p := range []string{"id", "reason", "override"} {
if _, has := props[p]; !has {
t.Errorf("restore has no input %s", p)
}
}
if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" {
t.Errorf("restore requires %v, want id and reason", req)
}
return
}
t.Error("no restore verb")
}
+1
View File
@@ -1984,6 +1984,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
problems = append(problems, declaredSeatProblems(m)...) problems = append(problems, declaredSeatProblems(m)...)
problems = append(problems, RunsAsProblems(m)...) problems = append(problems, RunsAsProblems(m)...)
problems = append(problems, ReadsHolderProblems(m)...) problems = append(problems, ReadsHolderProblems(m)...)
problems = append(problems, FilesHolderProblems(m)...)
if m.Computed != "" && len(m.Resources) > 0 { if m.Computed != "" && len(m.Resources) > 0 {
// One or the other. A module that both ships files and has them computed would leave // One or the other. A module that both ships files and has them computed would leave
// nobody able to say where a given file came from. // nobody able to say where a given file came from.
+4
View File
@@ -178,6 +178,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4).
if words, err = r.filesWords(m, words, with); err != nil {
return nil, err
}
if len(words) > 0 { if len(words) > 0 {
given[m.Module] = words given[m.Module] = words
} }
+3
View File
@@ -421,6 +421,9 @@ var defaultSeats = append([]Seat{
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, {Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
// on every node, then node-reads goes (ADR 0394 §9).
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
}, },
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
graphicalSessionSeats()...) graphicalSessionSeats()...)
+3 -2
View File
@@ -48,6 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
delivered[s.Delivers] = s.Name delivered[s.Delivers] = s.Name
} }
} }
// Forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes.
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
// into node-uplink (novox/hq ADR 0223); thirty-seven // into node-uplink (novox/hq ADR 0223); thirty-seven
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
@@ -59,8 +60,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
// mesh-build-machine row goes, when no registered manifest claims it. // mesh-build-machine row goes, when no registered manifest claims it.
if len(Seats()) != 44 { if len(Seats()) != 45 {
t.Errorf("the mesh defines %d seats rather than 44; the set is closed, so a change here is "+ t.Errorf("the mesh defines %d seats rather than 45; the set is closed, so a change here is "+
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
} }
} }