diff --git a/.gitignore b/.gitignore index 3a9d9d08..9adfdd46 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge modules/mesh-knowledge/mesh-knowledge modules/mesh-reads/mesh-reads modules/mesh-reads/cmd/mesh-reads/mesh-reads + +# The provisioner client built in place (issue 671). +modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client diff --git a/checks/config/go.mod b/checks/config/go.mod new file mode 100644 index 00000000..c651a52b --- /dev/null +++ b/checks/config/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/config + +go 1.22 diff --git a/checks/config/main.go b/checks/config/main.go new file mode 100644 index 00000000..d332c4e9 --- /dev/null +++ b/checks/config/main.go @@ -0,0 +1,201 @@ +// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348 +// clauses 1 and 11, design 57 package K6, issue 665). +// +// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it +// takes under `takes-config`, and a file asks for a value with `${config:}`. A mergeable file naming no keys at +// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config +// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`, +// `takes-settings` and `${setting:}` — stay accepted by the controller as aliases until every module has +// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this +// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its +// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a +// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks; +// while the trunk holds no copy, the modules in seed are the trunk's. +// When it is empty, the controller refuses the old names itself (design 57 package R1). +// +// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and +// rewording it would change what a node is sent. +// +// go run . [-base ] +// +// Exits 1 on a finding. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a +// string value is never followed by one. +var ( + settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`) + takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`) + oldPlaceholder = "${setting:…}" + oldTakes = "takes-settings" + oldDeclaration = "settings" + wholeLayer = "taking its whole layer" + listFile = "unmoved" + listPathInCatalog = "checks/config/" + listFile +) + +// Check answers, per module that says any, the settings form's names its manifest and its template files say. +func Check(root string) (map[string][]string, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + out := map[string][]string{} + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Settings json.RawMessage `json:"settings"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + var said []string + if len(m.Settings) > 0 && string(m.Settings) != "null" { + said = append(said, oldDeclaration) + } + if takesSettingsKey.Match(raw) { + said = append(said, oldTakes) + } + if settingRef.Match(raw) { + said = append(said, oldPlaceholder) + } + for _, r := range m.Resources { + // A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them. + if how, _ := r["merge"].(string); how != "" { + _, oldList := r["takes-settings"] + _, newList := r["takes-config"] + if !oldList && !newList { + said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer)) + } + } + name, _ := r["template"].(string) + if name == "" { + continue + } + text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name))) + if err != nil { + return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err) + } + if settingRef.Match(text) { + said = append(said, oldPlaceholder+" in "+name) + } + } + if len(said) > 0 { + sort.Strings(said) + out[m.Module] = said + } + } + return out, nil +} + +// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one. +// The modules taking a whole layer were found after the list was first written and before the trunk held a copy +// (issue 735), so they stand in it too. +var seed = map[string]bool{ + "betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true, + "baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true, + "icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true, +} + +// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules. +func Unmoved(path string) (map[string]bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + out := map[string]bool{} + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out[line] = true + } + return out, s.Err() +} + +// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that +// say none (stale), and the modules on the list that the trunk's list does not hold (added). +func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) { + for m := range found { + if !unmoved[m] { + refused = append(refused, m) + } + } + for m := range unmoved { + if _, says := found[m]; !says { + stale = append(stale, m) + } + if !base[m] { + added = append(added, m) + } + } + sort.Strings(refused) + sort.Strings(stale) + sort.Strings(added) + return refused, stale, added +} + +func main() { + basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent") + flag.Parse() + root := "." + if flag.NArg() > 0 { + root = flag.Arg(0) + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + unmoved, err := Unmoved(listFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + base := seed + if *basePath != "" { + if base, err = Unmoved(*basePath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + refused, stale, added := Judge(found, unmoved, base) + for _, m := range refused { + fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+ + "${config:} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog) + } + for _, m := range stale { + fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m) + } + for _, m := range added { + fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+ + "config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m) + } + if len(refused) > 0 || len(stale) > 0 || len(added) > 0 { + os.Exit(1) + } + fmt.Printf("config: no moved module says settings, takes-settings or ${setting:}, or takes a whole layer "+ + "(%d not yet moved)\n", + len(unmoved)) +} diff --git a/checks/config/main_test.go b/checks/config/main_test.go new file mode 100644 index 00000000..d650a16b --- /dev/null +++ b/checks/config/main_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func module(t *testing.T, root, name, body string, files map[string]string) { + t.Helper() + dir := filepath.Join(root, "modules", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + for path, text := range files { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a +// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1). +func TestEveryOldNameIsFound(t *testing.T) { + root := t.TempDir() + module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+ + `"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil) + module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+ + `"takes-settings":["a"],"content":"{}"}]}`, nil) + module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil) + module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+ + `"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`, + map[string]string{"files/conf": "size = ${setting:size}\n"}) + module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+ + `"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+ + `"takes-config":[],"content":"{\"settings\": 1}"}]}`, + map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"}) + module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + want := map[string][]string{ + "asks": {"${setting:…}"}, + "declares": {"${setting:…}", "settings"}, + "takes": {"takes-settings"}, + "templated": {"${setting:…} in files/conf"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("found %v, want %v", got, want) + } +} + +// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's +// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11). +func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) { + found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}} + unmoved := map[string]bool{"old": true, "done": true, "new": true} + base := map[string]bool{"old": true, "done": true, "forgotten": true} + refused, stale, added := Judge(found, unmoved, base) + if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) || + !reflect.DeepEqual(added, []string{"new"}) { + t.Fatalf("refused %v, stale %v, added %v", refused, stale, added) + } +} + +func TestTheListIsReadWithoutItsComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "unmoved") + if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Unmoved(path) + if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) { + t.Fatalf("%v %v", got, err) + } +} + +// The catalogue itself: every moved module says config alone, and the list names exactly the modules that +// still say settings. +func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) { + found, err := Check(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + unmoved, err := Unmoved("unmoved") + if err != nil { + t.Fatal(err) + } + refused, stale, added := Judge(found, unmoved, seed) + for _, m := range refused { + t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", ")) + } + for _, m := range stale { + t.Errorf("checks/config/unmoved names %s, which says no old name", m) + } + for _, m := range added { + t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m) + } +} + +// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings +// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It +// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list +// included, is not. +func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) { + root := t.TempDir() + module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+ + `"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"}) + module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+ + `"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil) + module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+ + `"takes-config":[],"content":"{}"}]}`, nil) + module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + want := map[string][]string{"whole": {"runtime-config taking its whole layer"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("found %v, want %v", got, want) + } +} diff --git a/checks/config/unmoved b/checks/config/unmoved new file mode 100644 index 00000000..c8ca7e28 --- /dev/null +++ b/checks/config/unmoved @@ -0,0 +1,5 @@ +# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:}`, or a mergeable +# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to +# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no +# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller +# refuses the old names (design 57 package R1). diff --git a/checks/mailstore/go.mod b/checks/mailstore/go.mod new file mode 100644 index 00000000..0d161eef --- /dev/null +++ b/checks/mailstore/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/mailstore + +go 1.22 diff --git a/checks/mailstore/main.go b/checks/mailstore/main.go new file mode 100644 index 00000000..589d49dd --- /dev/null +++ b/checks/mailstore/main.go @@ -0,0 +1,81 @@ +// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378 +// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second +// claimant would be a second path to the same mail that ADR 0378 never accounted for. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// MailStoreSeat is the seat only mesh-mail may claim. +const MailStoreSeat = "mail-store" + +// MailStoreModule is the one module allowed to claim it. +const MailStoreModule = "mesh-mail" + +// Finding is one manifest refused: a module other than mesh-mail claiming mail-store. +type Finding struct { + Module string +} + +// Check reads every manifest under root/modules and answers which ones it refuses. +func Check(root string) ([]Finding, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + var out []Finding + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Claims []struct { + Name string `json:"name"` + } `json:"claims"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if m.Module == MailStoreModule { + continue + } + for _, c := range m.Claims { + if c.Name == MailStoreSeat { + out = append(out, Finding{Module: m.Module}) + break + } + } + } + return out, nil +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, f := range found { + fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("mail-store: claimed only by mesh-mail") +} diff --git a/checks/mailstore/main_test.go b/checks/mailstore/main_test.go new file mode 100644 index 00000000..e33470da --- /dev/null +++ b/checks/mailstore/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func manifest(t *testing.T, root, module, claims string) { + t.Helper() + dir := filepath.Join(root, "modules", module) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"module":"` + module + `","claims":[` + claims + `]}` + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) { + root := t.TempDir() + manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`) + manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`) + manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Module != "betterbird" { + t.Fatalf("refused %+v", found) + } +} + +func TestTheCatalogueHasNone(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + if len(found) != 0 { + t.Fatalf("%+v", found) + } +} diff --git a/checks/secrets/main.go b/checks/secrets/main.go index 4a317967..a73c7059 100644 --- a/checks/secrets/main.go +++ b/checks/secrets/main.go @@ -7,7 +7,8 @@ // until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`, // naming the layer and the key, never the value. This check holds every such file to that: each top-level key // whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out -// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. +// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template +// in files/ is judged by the template's text. // // A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot // be protected alone today: the controller protects top-level keys. Such a file is named in the file `known` @@ -64,6 +65,15 @@ func Check(root string) ([]Finding, error) { } id, _ := r["id"].(string) content, _ := r["content"].(string) + // A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text, + // exactly as it would be inline. + if name, _ := r["template"].(string); name != "" { + text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name))) + if err != nil { + return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err) + } + content = string(text) + } var doc map[string]any if strings.TrimSpace(content) == "" { continue diff --git a/checks/secrets/main_test.go b/checks/secrets/main_test.go index 48b0bd23..efa9750a 100644 --- a/checks/secrets/main_test.go +++ b/checks/secrets/main_test.go @@ -51,6 +51,35 @@ func TestASecretASettingCouldReplaceIsRefused(t *testing.T) { } } +// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text, +// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass. +func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) { + root := t.TempDir() + manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`) + manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`) + for _, m := range []string{"a", "c"} { + dir := filepath.Join(root, "modules", m, "files") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil { + t.Fatal(err) + } + } + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].String() != "a cfg password" { + t.Fatalf("found %v, want [a cfg password]", found) + } + + manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`) + if _, err := Check(root); err == nil { + t.Fatal("a template the module's directory lacks passed the check") + } +} + func TestTheKnownListOnlyShrinks(t *testing.T) { found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}} refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true}) diff --git a/checks/templates/go.mod b/checks/templates/go.mod new file mode 100644 index 00000000..f6c19294 --- /dev/null +++ b/checks/templates/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/templates + +go 1.22 diff --git a/checks/templates/inline b/checks/templates/inline new file mode 100644 index 00000000..9d845aee --- /dev/null +++ b/checks/templates/inline @@ -0,0 +1,9 @@ +# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of +# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the +# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their +# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy +# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5, +# issue 658). +# +# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template; +# it moves with the collections (design 57 package L2, issue 681) and is not judged here. diff --git a/checks/templates/main.go b/checks/templates/main.go new file mode 100644 index 00000000..792d9e01 --- /dev/null +++ b/checks/templates/main.go @@ -0,0 +1,178 @@ +// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq +// ADR 0349 §2, design 57 package T5, issue 658). +// +// A file resource names its text as a template in its module's templates directory (`"template": "/"`; +// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an +// archive; the controller reads each template back into the file's content, so the +// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is +// listed in the file `inline` beside this check, one per line. Every other module's file resources say no +// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge +// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the +// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text. +// +// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the +// collections (design 57 package L2, issue 681). +// +// go run . [-base ] +// +// Every refusal names the refused module's own templates directory. Exits 1 on a finding. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + listFile = "inline" + listPathInCatalog = "checks/templates/" + listFile +) + +// Check answers, per module that keeps any, the ids of its file resources that say their text inline. +func Check(root string) (map[string][]string, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + out := map[string][]string{} + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline && r["type"] == "file" { + out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"])) + } + } + } + return out, nil +} + +// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's +// `templates`, files/ when the manifest says none or cannot be read here. +func TemplatesDir(root, module string) string { + dir := "files/" + raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json")) + if err != nil { + return dir + } + var m struct { + Templates string `json:"templates"` + } + if json.Unmarshal(raw, &m) == nil && m.Templates != "" { + dir = strings.TrimSuffix(m.Templates, "/") + "/" + } + return dir +} + +// Refusal is the finding for a module that keeps file text inline and is not on the list. +func Refusal(module string, ids []string, dir string) string { + return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+ + "the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)", + module, strings.Join(ids, ", "), listPathInCatalog, dir, dir) +} + +// Gained is the finding for a module the list gains against the trunk's copy. +func Gained(module, dir string) string { + return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+ + "to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir) +} + +// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one. +var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true} + +// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules. +func Listed(path string) (map[string]bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + out := map[string]bool{} + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out[line] = true + } + return out, s.Err() +} + +// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list +// that keep none (stale), and the modules on the list that the trunk's list does not hold (added). +func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) { + for m := range found { + if !listed[m] { + refused = append(refused, m) + } + } + for m := range listed { + if _, keeps := found[m]; !keeps { + stale = append(stale, m) + } + if !base[m] { + added = append(added, m) + } + } + sort.Strings(refused) + sort.Strings(stale) + sort.Strings(added) + return refused, stale, added +} + +func main() { + basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent") + flag.Parse() + root := "." + if flag.NArg() > 0 { + root = flag.Arg(0) + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + listed, err := Listed(listFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + base := seed + if *basePath != "" { + if base, err = Listed(*basePath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + refused, stale, added := Judge(found, listed, base) + for _, m := range refused { + fmt.Println(Refusal(m, found[m], TemplatesDir(root, m))) + } + for _, m := range stale { + fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m) + } + for _, m := range added { + fmt.Println(Gained(m, TemplatesDir(root, m))) + } + if len(refused) > 0 || len(stale) > 0 || len(added) > 0 { + os.Exit(1) + } + fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed)) +} diff --git a/checks/templates/main_test.go b/checks/templates/main_test.go new file mode 100644 index 00000000..c7d0d5b3 --- /dev/null +++ b/checks/templates/main_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func module(t *testing.T, root, name, body string) { + t.Helper() + dir := filepath.Join(root, "modules", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// A file resource that says its text inline is found, by module and resource; one naming its template, a +// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2). +func TestEveryInlineFileTextIsFound(t *testing.T) { + root := t.TempDir() + module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+ + `{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`) + module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+ + `{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + want := map[string][]string{"inline": {"a", "c"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("found %v, want %v", got, want) + } +} + +// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the +// trunk's list does not hold is refused, so the list only shrinks until it is empty. +func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) { + found := map[string][]string{"old": {"a"}, "forgotten": {"b"}} + inline := map[string]bool{"old": true, "done": true, "new": true} + base := map[string]bool{"old": true, "done": true, "forgotten": true} + refused, stale, added := Judge(found, inline, base) + if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) || + !reflect.DeepEqual(added, []string{"new"}) { + t.Fatalf("refused %v, stale %v, added %v", refused, stale, added) + } +} + +func TestTheListIsReadWithoutItsComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "inline") + if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Listed(path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) { + t.Fatalf("read %v", got) + } +} + +// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none +// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved). +func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + inline, err := Listed(listFile) + if err != nil { + t.Fatal(err) + } + refused, stale, _ := Judge(found, inline, inline) + if len(refused) > 0 || len(stale) > 0 { + t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale) + } +} + +// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809). +func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) { + root := t.TempDir() + module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`) + module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`) + module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`) + for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} { + if got := TemplatesDir(root, module); got != dir { + t.Fatalf("%s: directory %q, want %q", module, got, dir) + } + } + msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own")) + if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") { + t.Fatalf("refusal %q", msg) + } + if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") { + t.Fatalf("gained %q", msg) + } +} diff --git a/health-undeclared b/health-undeclared index 1723e8c2..29ac652b 100644 --- a/health-undeclared +++ b/health-undeclared @@ -1,4 +1,4 @@ -66 +62 The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running diff --git a/merge-check-typescript.sh b/merge-check-typescript.sh index 3b0ed856..b32bb08f 100644 --- a/merge-check-typescript.sh +++ b/merge-check-typescript.sh @@ -23,9 +23,51 @@ # # By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads # /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same. +# +# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is +# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage +# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this +# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last +# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a +# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm +# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor +# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is +# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner). set -eu +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + deps="${TOOLCHAIN_MODULES:-/app/node_modules}" +if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then + origin=$(git remote get-url origin 2>/dev/null || true) + forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}" + owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}" + if [ -z "$forge" ] || [ -z "$owner" ]; then + echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)" + exit 1 + fi + echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)" + tc="$work/toolchain" + mkdir -p "$tc" + printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc" + # Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails. + if ! { node -e ' + const [base, dir] = process.argv.slice(1); + Promise.all(["package.json", "package-lock.json"].map(async (f) => { + const r = await fetch(base + f); + if (!r.ok) throw new Error(base + f + ": " + r.status); + require("fs").writeFileSync(dir + "/" + f, await r.text()); + })).catch((e) => { console.error(e.message); process.exit(1); });' \ + "$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" && + (cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" && + npm ci --ignore-scripts --no-audit --no-fund --loglevel=error && + npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then + echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)" + exit 1 + fi + deps="$tc/node_modules" +fi tsc="$deps/typescript/bin/tsc" if [ ! -f "$tsc" ]; then echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain" @@ -39,8 +81,6 @@ touched=$({ printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' } | sed '/^$/d' | sort -u) -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT failed="" for m in $touched; do d="modules/$m" diff --git a/merge-check.sh b/merge-check.sh index f007eb27..0e4e4cdc 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -37,11 +37,21 @@ # module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a # value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and # every manifest against it are judged by the controller's module check in step 1. +# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or +# `${setting:}`, in its manifest or a template it names, or has a mergeable file naming no keys (which +# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks, +# judged against the trunk's copy at the merge base. +# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`) +# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list +# only shrinks, judged against the trunk's copy at the merge base. +# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it. set -eu (cd checks/words && go test -count=1 . && go run . ../..) # The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it. (cd checks/agenthome && go test -count=1 . && go run . ../..) +# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it. +(cd checks/mailstore && go test -count=1 . && go run . ../..) # The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The # known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused. secrets_base="" @@ -67,6 +77,30 @@ else echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen" fi (cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi) +# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved +# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy. +unmoved_base="" +if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then + if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then + unmoved_base=$(mktemp) + git show "$merge_base:checks/config/unmoved" >"$unmoved_base" + fi +else + echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with" +fi +(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi) +# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the +# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first. +inline_base="" +if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then + if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then + inline_base=$(mktemp) + git show "$merge_base:checks/templates/inline" >"$inline_base" + fi +else + echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with" +fi +(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi) # The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are # judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in. base_lists="" diff --git a/modules/adwaita/README.md b/modules/adwaita/README.md index c8ff628a..040a46d2 100644 --- a/modules/adwaita/README.md +++ b/modules/adwaita/README.md @@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca | path | class | from | |---|---|---| -| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) | +| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) | | `~/.config/gtk-4.0/settings.ini` | owned | the same file | -| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) | -| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) | -| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources | +| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) | +| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) | +| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources | **`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push, and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it diff --git a/modules/adwaita/cmd/adwaita-tools/adwaita_test.go b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go index fc1b3264..be9d84b9 100644 --- a/modules/adwaita/cmd/adwaita-tools/adwaita_test.go +++ b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go @@ -18,10 +18,18 @@ type manifest struct { Environment struct { Variables map[string]string `json:"variables"` } `json:"environment"` + Items []struct { + Collection string `json:"collection"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` Shell []struct { For, Slot, Code string } `json:"shell"` Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` } func readManifest(t *testing.T) manifest { @@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + // The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807). + for _, it := range m.Items { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(it.Collection, ":") + switch { + case len(parts) == 3 && parts[0] == "shell": + m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)}) + case it.Collection == "environment" && it.Attributes["kind"] == "variable": + if m.Environment.Variables == nil { + m.Environment.Variables = map[string]string{} + } + m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + } return m } @@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { pkgs = append(pkgs, r["package"].(string)) continue } - raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id])) - if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" { - t.Errorf("%s is not config/%s, whole and the account's", id, sources[id]) + _, inline := r["content"] + if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline || + r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" { + t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id]) } if strings.Contains(r["path"].(string), "qt5ct") { t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate") @@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" { t.Fatalf("%v", pkgs) } - qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf")) + qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf")) if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") { t.Fatalf("%v", qt) } @@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"]) } } - gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"] + gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"] if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" { t.Fatalf("%v", gtk) } @@ -244,3 +270,31 @@ func asJSON(v any) string { b, _ := json.Marshal(v) return string(b) } + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + m := readManifest(t) + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if name, ok := r["template"].(string); ok { + if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil { + t.Errorf("resource %v names %s: %v", r["id"], name, err) + } + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/adwaita/config/cursor-index.theme b/modules/adwaita/files/cursor-index.theme similarity index 100% rename from modules/adwaita/config/cursor-index.theme rename to modules/adwaita/files/cursor-index.theme diff --git a/modules/adwaita/files/env-gtk-theme b/modules/adwaita/files/env-gtk-theme new file mode 100644 index 00000000..b98c8093 --- /dev/null +++ b/modules/adwaita/files/env-gtk-theme @@ -0,0 +1 @@ +Adwaita:dark diff --git a/modules/adwaita/files/env-gtk2-rc-files b/modules/adwaita/files/env-gtk2-rc-files new file mode 100644 index 00000000..8a782fb8 --- /dev/null +++ b/modules/adwaita/files/env-gtk2-rc-files @@ -0,0 +1 @@ +/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc diff --git a/modules/adwaita/files/env-qt-qpa-platformtheme b/modules/adwaita/files/env-qt-qpa-platformtheme new file mode 100644 index 00000000..349e5d6b --- /dev/null +++ b/modules/adwaita/files/env-qt-qpa-platformtheme @@ -0,0 +1 @@ +qt6ct diff --git a/modules/adwaita/files/env-qt-select b/modules/adwaita/files/env-qt-select new file mode 100644 index 00000000..1e8b3149 --- /dev/null +++ b/modules/adwaita/files/env-qt-select @@ -0,0 +1 @@ +6 diff --git a/modules/adwaita/files/env-qt-style-override b/modules/adwaita/files/env-qt-style-override new file mode 100644 index 00000000..a0da7db8 --- /dev/null +++ b/modules/adwaita/files/env-qt-style-override @@ -0,0 +1 @@ +Fusion diff --git a/modules/adwaita/files/env-xcursor-size b/modules/adwaita/files/env-xcursor-size new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/modules/adwaita/files/env-xcursor-size @@ -0,0 +1 @@ +24 diff --git a/modules/adwaita/files/env-xcursor-theme b/modules/adwaita/files/env-xcursor-theme new file mode 100644 index 00000000..cb9676b8 --- /dev/null +++ b/modules/adwaita/files/env-xcursor-theme @@ -0,0 +1 @@ +Adwaita diff --git a/modules/adwaita/config/gtk-settings.ini b/modules/adwaita/files/gtk-settings.ini similarity index 100% rename from modules/adwaita/config/gtk-settings.ini rename to modules/adwaita/files/gtk-settings.ini diff --git a/modules/adwaita/config/portals.conf b/modules/adwaita/files/portals.conf similarity index 100% rename from modules/adwaita/config/portals.conf rename to modules/adwaita/files/portals.conf diff --git a/modules/adwaita/config/qt6ct.conf b/modules/adwaita/files/qt6ct.conf similarity index 100% rename from modules/adwaita/config/qt6ct.conf rename to modules/adwaita/files/qt6ct.conf diff --git a/modules/adwaita/files/xinitrc-normal b/modules/adwaita/files/xinitrc-normal new file mode 100644 index 00000000..864b3cf6 --- /dev/null +++ b/modules/adwaita/files/xinitrc-normal @@ -0,0 +1,10 @@ +# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is +# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the +# module's default; adwaita_appearance switches it for a session. +gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true +gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-size 24 || true +gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true +gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true diff --git a/modules/adwaita/files/xresources-normal b/modules/adwaita/files/xresources-normal new file mode 100644 index 00000000..02e7ec33 --- /dev/null +++ b/modules/adwaita/files/xresources-normal @@ -0,0 +1,3 @@ +! adwaita: the cursor, for X programs that take it from the resources. +Xcursor.theme: Adwaita +Xcursor.size: 24 diff --git a/modules/adwaita/module.json b/modules/adwaita/module.json index c02971ea..bf81c833 100644 --- a/modules/adwaita/module.json +++ b/modules/adwaita/module.json @@ -1,6 +1,7 @@ { "module": "adwaita", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -10,27 +11,79 @@ "adwaita_icons", "adwaita_portal_check" ], - "environment": { - "variables": { - "GTK_THEME": "Adwaita:dark", - "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", - "QT_QPA_PLATFORMTHEME": "qt6ct", - "QT_STYLE_OVERRIDE": "Fusion", - "QT_SELECT": "6", - "XCURSOR_THEME": "Adwaita", - "XCURSOR_SIZE": "24" - } - }, - "shell": [ + "items": [ { - "for": "xresources", - "slot": "normal", - "code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" + "collection": "environment", + "name": "gtk-theme", + "attributes": { + "kind": "variable", + "name": "GTK_THEME" + }, + "template": "files/env-gtk-theme" }, { - "for": "xinitrc", - "slot": "normal", - "code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n" + "collection": "environment", + "name": "gtk2-rc-files", + "attributes": { + "kind": "variable", + "name": "GTK2_RC_FILES" + }, + "template": "files/env-gtk2-rc-files" + }, + { + "collection": "environment", + "name": "qt-qpa-platformtheme", + "attributes": { + "kind": "variable", + "name": "QT_QPA_PLATFORMTHEME" + }, + "template": "files/env-qt-qpa-platformtheme" + }, + { + "collection": "environment", + "name": "qt-style-override", + "attributes": { + "kind": "variable", + "name": "QT_STYLE_OVERRIDE" + }, + "template": "files/env-qt-style-override" + }, + { + "collection": "environment", + "name": "qt-select", + "attributes": { + "kind": "variable", + "name": "QT_SELECT" + }, + "template": "files/env-qt-select" + }, + { + "collection": "environment", + "name": "xcursor-theme", + "attributes": { + "kind": "variable", + "name": "XCURSOR_THEME" + }, + "template": "files/env-xcursor-theme" + }, + { + "collection": "environment", + "name": "xcursor-size", + "attributes": { + "kind": "variable", + "name": "XCURSOR_SIZE" + }, + "template": "files/env-xcursor-size" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + }, + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" } ], "resources": [ @@ -65,7 +118,7 @@ "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + "template": "files/gtk-settings.ini" }, { "id": "gtk4", @@ -73,7 +126,7 @@ "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + "template": "files/gtk-settings.ini" }, { "id": "qt6ct", @@ -81,7 +134,7 @@ "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", "owner": "${machine:account}", "mode": "0644", - "content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n" + "template": "files/qt6ct.conf" }, { "id": "portals", @@ -89,7 +142,7 @@ "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n" + "template": "files/portals.conf" }, { "id": "cursor", @@ -97,7 +150,7 @@ "path": "${machine:account-home}/.icons/default/index.theme", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n" + "template": "files/cursor-index.theme" } ], "build": { @@ -112,6 +165,11 @@ "loads": [ "adwaita-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/artifact-store-tools/files/store-names b/modules/artifact-store-tools/files/store-names new file mode 100644 index 00000000..29cab310 --- /dev/null +++ b/modules/artifact-store-tools/files/store-names @@ -0,0 +1,4 @@ +# The names the artifact store had before, from the setting older-store-names, written by the mesh. +# artifact_store_references reads every definition under these too (novox/hq issue 323). +# Replaced on every push; change the setting, never this file. +older=${config:older-store-names} diff --git a/modules/artifact-store-tools/module.json b/modules/artifact-store-tools/module.json index d17ac5d2..fb6b34bb 100644 --- a/modules/artifact-store-tools/module.json +++ b/modules/artifact-store-tools/module.json @@ -13,13 +13,14 @@ "artifact_store_references", "artifact_store_collect" ], - "settings": { - "older-store-names": { - "kind": "preference", - "default": "none", - "why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)" - } - }, + "config": { + "older-store-names": { + "type": "string", + "description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).", + "pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*", + "default": "none" + } + }, "resources": [ { "id": "state", @@ -33,7 +34,7 @@ "path": "${dir:state}/store-names", "mode": "0600", "trusted": false, - "content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n" + "template": "files/store-names" } ], "build": { @@ -52,6 +53,11 @@ "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go index d289c14e..d435c882 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go @@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) { home := "/home/operator" t.Setenv("MESH_OPERATOR_HOME", home) // The i3 module's file as the controller composes it: other modules' lines around this one's. - var own string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" { - own += c.Content - } + own := m.i3Lines() + if own == "" || len(m.Contributions) != 0 { + t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions) } put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+ "# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n") diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go index 410c78ac..d300d0f6 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go @@ -15,19 +15,41 @@ type manifest struct { Tools []string `json:"tools"` Emits []string `json:"emits"` Resources []map[string]any `json:"resources"` - // Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys. - Contributions []struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` - } `json:"contributions"` + // Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys + // triggers, its text the template file beside the manifest. + Items []struct { + Collection string `json:"collection"` + Template string `json:"template"` + } `json:"items"` + // Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left. + Contributions []any `json:"contributions"` } -// triggers is the module's contribution to node-hotkeys: its vendor keys. +// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). +func (m manifest) i3Lines() string { + for _, it := range m.Items { + if it.Collection == "contribution:node-display-session:config" { + raw, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + return string(raw) + } + } + return "" +} + +// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its +// item of the collection contribution:node-hotkeys:trigger. func (m manifest) triggers() string { - for _, c := range m.Contributions { - if c.Seat == "node-hotkeys" && c.Kind == "trigger" { - return c.Content + for _, it := range m.Items { + if it.Collection == "contribution:node-hotkeys:trigger" { + raw, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + return string(raw) } } return "" diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/templates_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/templates_test.go new file mode 100644 index 00000000..cc5a736f --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/templates_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731), +// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given. +func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Templates string `json:"templates"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Templates != "templates" { + t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates) + } + files := 0 + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"]) + } + if r["type"] != "file" { + continue + } + files++ + name, _ := r["template"].(string) + if !strings.HasPrefix(name, "templates/") { + t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name) + continue + } + if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() { + t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err) + } + } + if files == 0 { + t.Fatal("no file resource") + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "templates" { + packs++ + if a["name"] != "templates" { + t.Errorf("the templates archive is named %v, not templates", a["name"]) + } + } + } + if packs != 1 { + t.Fatalf("the build packs templates/ into %d archives, not one", packs) + } + // The templates archive is read by the controller, never unpacked onto the machine. + for _, r := range m.Resources { + if r["artifact"] == "templates" { + t.Errorf("resource %v places the templates archive on the machine", r["id"]) + } + } + // And files/ holds what the machine runs, no template. + err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") { + t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json index 13a2fbf8..77869927 100644 --- a/modules/asus-zephyrus-g14/module.json +++ b/modules/asus-zephyrus-g14/module.json @@ -1,6 +1,8 @@ { "module": "asus-zephyrus-g14", "version": "1", + "moved": true, + "templates": "templates", "capabilities": [ "package-manager", "service-manager" @@ -59,14 +61,14 @@ "type": "file", "path": "/etc/modprobe.d/g14-nvidia-power.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n" + "template": "templates/modprobe.d/g14-nvidia-power.conf" }, { "id": "video-options", "type": "file", "path": "/etc/modprobe.d/video-brightness-switch.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n" + "template": "templates/modprobe.d/video-brightness-switch.conf" }, { "id": "suspend-drop-ins", @@ -79,7 +81,7 @@ "type": "file", "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n" + "template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "hibernate-drop-ins", @@ -92,7 +94,7 @@ "type": "file", "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n" + "template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "suspend-then-hibernate-drop-ins", @@ -105,7 +107,7 @@ "type": "file", "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n" + "template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "powerd-drop-ins", @@ -118,14 +120,14 @@ "type": "file", "path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n" + "template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf" }, { "id": "backlight-rule", "type": "file", "path": "/etc/udev/rules.d/90-backlight.rules", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n" + "template": "templates/udev/rules.d/90-backlight.rules" }, { "id": "udev", @@ -151,7 +153,7 @@ "type": "file", "path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n" + "template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf" }, { "id": "upower", @@ -174,7 +176,22 @@ "type": "file", "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n" + "template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf" + } + ], + "items": [ + { + "collection": "shell:after-wake:normal", + "template": "templates/after-wake" + }, + { + "collection": "contribution:node-hotkeys:trigger", + "template": "templates/hotkeys-triggers" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "templates/display-session" } ], "build": { @@ -194,31 +211,12 @@ "name": "scripts", "kind": "archive", "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] - }, - "contributions": [ - { - "seat": "node-hotkeys", - "kind": "trigger", - "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" - }, - { - "seat": "node-display-session", - "kind": "config", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n" - }, - { - "seat": "node-display-session", - "kind": "config", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" - } - ], - "shell": [ - { - "for": "after-wake", - "slot": "normal", - "code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n" - } - ] + } } diff --git a/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf b/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf new file mode 100644 index 00000000..f3d6bef5 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little, +# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's. +[UPower] +UsePercentageForPolicy=true +PercentageLow=15.0 +PercentageCritical=10.0 +PercentageAction=7.0 +CriticalPowerAction=Suspend +AllowRiskyCriticalPowerAction=true diff --git a/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf b/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf new file mode 100644 index 00000000..f30830c6 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf @@ -0,0 +1,12 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The touchpad's settings, applied by X every time the device appears — at login and after every +# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran +# xinput after a resume as a named person on a guessed display. +Section "InputClass" + Identifier "asus-zephyrus-g14 touchpad" + MatchIsTouchpad "on" + Option "Tapping" "on" + Option "NaturalScrolling" "true" + Option "AccelSpeed" "0.15" +EndSection diff --git a/modules/asus-zephyrus-g14/templates/after-wake b/modules/asus-zephyrus-g14/templates/after-wake new file mode 100644 index 00000000..af60a9d9 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/after-wake @@ -0,0 +1,7 @@ +# The touchpad's settings again after waking, in the operator's session: X applies the module's +# input class when the device appears, and this covers a wake that does not initialise it again. +# Runs as root from the power module; the reset itself runs as the session's owner. +sleep 2 +owner=$(ps -o user= -C i3 | head -n 1) +[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset +true diff --git a/modules/asus-zephyrus-g14/templates/display-session b/modules/asus-zephyrus-g14/templates/display-session new file mode 100644 index 00000000..38f9e5bd --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/display-session @@ -0,0 +1,30 @@ +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are +# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for +# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's. + +# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the +# screenshot grabs the pointer to select a region, which fails while the key is still held. +bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the +# external output. +bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order + +# The keyboard backlight's level, shown when it changes. +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The model's panel and touchpad. + +# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where +# is the display server's (autorandr, run at every session start). +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary + +# The touchpad's settings again, by hand. X applies them itself whenever the device appears. +bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/modules/asus-zephyrus-g14/templates/hotkeys-triggers b/modules/asus-zephyrus-g14/templates/hotkeys-triggers new file mode 100644 index 00000000..5e4d165c --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/hotkeys-triggers @@ -0,0 +1,10 @@ +# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness, +# and the touchpad key. Each runs this module's own script, as the operator's account. +KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause +KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous +KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next +KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf b/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf new file mode 100644 index 00000000..78941022 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf @@ -0,0 +1,16 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics). +# +# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends +# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to +# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is +# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up. +# +# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still +# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this +# module's drop-ins on the sleep services ask for. +# +# A change here applies when the driver next loads: at the next boot. +options nvidia NVreg_PreserveVideoMemoryAllocations=1 +options nvidia NVreg_DynamicPowerManagement=0x00 diff --git a/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf b/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf new file mode 100644 index 00000000..5b2de1d5 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf @@ -0,0 +1,6 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it +# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf). +# Applies when the module next loads: at the next boot. +options video brightness_switch_enabled=0 diff --git a/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf b/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf new file mode 100644 index 00000000..4466dd08 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change +# of power source, and asusd starts it on mains. It runs only when the kernel command line says +# zephyrus.nvidia-powerd — an explicit opt-in, at boot. +[Unit] +ConditionKernelCommandLine=zephyrus.nvidia-powerd diff --git a/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..1f56e3b8 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-hibernate.service nvidia-resume.service diff --git a/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..5813188a --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service diff --git a/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..92a01117 --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend.service nvidia-resume.service diff --git a/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules b/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules new file mode 100644 index 00000000..0a9a894d --- /dev/null +++ b/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules @@ -0,0 +1,5 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The backlights are writable by the video group, so the brightness keys and the module's brightness tool +# move the panel without root. +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/modules/baserow/files/config.json b/modules/baserow/files/config.json new file mode 100644 index 00000000..955ac244 --- /dev/null +++ b/modules/baserow/files/config.json @@ -0,0 +1,4 @@ +{ + "password": "${secret:admin}", + "host": "${bound:route:name}" +} diff --git a/modules/baserow/files/server.env b/modules/baserow/files/server.env new file mode 100644 index 00000000..7b716b42 --- /dev/null +++ b/modules/baserow/files/server.env @@ -0,0 +1,7 @@ +DATABASE_HOST=${bound:postgres-database:at} +DATABASE_PORT=${bound:postgres-database:port} +DATABASE_NAME=${bound:postgres-database:as} +DATABASE_USER=${bound:postgres-database:as} +DATABASE_PASSWORD_FILE=/run/secrets/database +DISABLE_EMBEDDED_PSQL=true +BASEROW_PUBLIC_URL=https://${bound:route:name} diff --git a/modules/baserow/module.json b/modules/baserow/module.json index 366ab9c0..e19568b6 100644 --- a/modules/baserow/module.json +++ b/modules/baserow/module.json @@ -46,6 +46,26 @@ } ] }, + "config": { + "host": { + "type": "string", + "description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.", + "trusted": true, + "default": "${bound:route:name}" + }, + "email": { + "type": "string", + "description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -70,7 +90,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n" + "template": "files/server.env" }, { "id": "net", @@ -102,8 +122,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", + "template": "files/config.json", "merge": "json", + "takes-config": ["host", "email", "url"], "protected": ["password"] } ], @@ -123,6 +144,11 @@ "MESH_BASEROW_URL": "http://127.0.0.1:${port:80}", "MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/betterbird/README.md b/modules/betterbird/README.md index 4e2a2acd..628f32e3 100644 --- a/modules/betterbird/README.md +++ b/modules/betterbird/README.md @@ -208,7 +208,7 @@ node set up from nothing, the declared accounts are written at the second start Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as -`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5). +`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve. **No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines. diff --git a/modules/betterbird/cmd/betterbird-tools/configuration.go b/modules/betterbird/cmd/betterbird-tools/configuration.go index 9a1620d3..97e0ad52 100644 --- a/modules/betterbird/cmd/betterbird-tools/configuration.go +++ b/modules/betterbird/cmd/betterbird-tools/configuration.go @@ -7,7 +7,8 @@ package main // // - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as // accounts is (§5), because each names where a password, a reply or forwarded mail goes; -// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted. +// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751, +// so a change to it is the operator's to approve. // // An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its // own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a diff --git a/modules/betterbird/cmd/betterbird-tools/manifest_test.go b/modules/betterbird/cmd/betterbird-tools/manifest_test.go index 1a7846d0..37e420b5 100644 --- a/modules/betterbird/cmd/betterbird-tools/manifest_test.go +++ b/modules/betterbird/cmd/betterbird-tools/manifest_test.go @@ -25,7 +25,7 @@ type manifest struct { Upgrade map[string]string `json:"upgrade"` Requires []string `json:"requires"` Invokes []string `json:"invokes"` - Settings map[string]any `json:"settings"` + Config map[string]any `json:"config"` Tools []string `json:"tools"` Resources []map[string]any `json:"resources"` Build struct { @@ -114,13 +114,21 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te } } -// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that -// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the -// definition, since each is the operator's own value; and each file's default declares nothing. -func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) { +// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the +// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's +// decision); each is an object whose default is the file's own, empty, never required, since a node without the +// operator's setup still runs Betterbird; and each file's default declares nothing. +func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) { m, _ := readManifest(t) - if len(m.Settings) != 0 { - t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings) + if _, said := m.Config["tags"]; !said || len(m.Config) != 7 { + t.Fatalf("the seven values are declared under config: %v", m.Config) + } + for key, raw := range m.Config { + d, _ := raw.(map[string]any) + def, _ := d["default"].(map[string]any) + if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 { + t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d) + } } files := map[string]struct { path, mode string @@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t "prefs": {prefsFile, "0644", []any{"prefs"}, true}, "accounts": {accountsFile, "0600", []any{"accounts"}, true}, "configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true}, - "tags": {tagsFile, "0644", []any{"tags"}, false}, + "tags": {tagsFile, "0644", []any{"tags"}, true}, } merging := 0 for _, r := range m.Resources { @@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t for id, want := range files { r := resource(t, m, id) if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted || - r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) { + r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) { t.Errorf("%s: %v", id, r) } var content map[string]any - if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) { + if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) { t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err) } } - got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string))) + got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts")))) if err != nil || len(got) != 0 { t.Fatalf("its default declares no account: %v %v", got, err) } - prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string))) + prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs")))) if err != nil || len(prefs) != 0 { t.Fatalf("its default declares no preference: %v %v", prefs, err) } var c Configuration - if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil { + if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil { t.Fatal(err) } - if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil { + if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil { t.Fatal(err) } } @@ -268,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing. func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) { m, _ := readManifest(t) start := resource(t, m, "start") - content := start["content"].(string) + content := templateText(t, start) if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" || !strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") { t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start) } launcher := resource(t, m, "launcher") - lc := launcher["content"].(string) + lc := templateText(t, launcher) if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" || !strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") || !strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") { diff --git a/modules/betterbird/cmd/betterbird-tools/templates_test.go b/modules/betterbird/cmd/betterbird-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/betterbird/cmd/betterbird-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/betterbird/files/accounts.json b/modules/betterbird/files/accounts.json new file mode 100644 index 00000000..e182cc3d --- /dev/null +++ b/modules/betterbird/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/modules/betterbird/files/betterbird b/modules/betterbird/files/betterbird new file mode 100755 index 00000000..3ce088a8 --- /dev/null +++ b/modules/betterbird/files/betterbird @@ -0,0 +1,10 @@ +#!/bin/sh +# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push. +# +# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars, +# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do +# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a +# second start while one runs only hands over to it. What the preparation could not do it says here, and +# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back. +/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2 +exec /opt/betterbird/betterbird "$@" diff --git a/modules/betterbird/files/configuration.json b/modules/betterbird/files/configuration.json new file mode 100644 index 00000000..70204462 --- /dev/null +++ b/modules/betterbird/files/configuration.json @@ -0,0 +1,6 @@ +{ + "identities": {}, + "calendars": {}, + "address-books": {}, + "filters": {} +} diff --git a/modules/betterbird/files/eu.betterbird.Betterbird.desktop b/modules/betterbird/files/eu.betterbird.Betterbird.desktop new file mode 100644 index 00000000..e409fcf8 --- /dev/null +++ b/modules/betterbird/files/eu.betterbird.Betterbird.desktop @@ -0,0 +1,25 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Betterbird +GenericName=Mail Client +Comment=Send and receive mail with Betterbird +Exec=/usr/local/bin/betterbird %u +Icon=/opt/betterbird/chrome/icons/default/default128.png +Terminal=false +Categories=Network;Email; +MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid; +StartupNotify=true +StartupWMClass=eu.betterbird.Betterbird +Keywords=Email;E-mail;Calendar;Thunderbird; +Actions=ComposeMessage;OpenAddressBook; +# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry: +# it starts Betterbird through the module's start, and names the icon in the installation, with no link. + +[Desktop Action ComposeMessage] +Name=Write new message +Exec=/usr/local/bin/betterbird -compose + +[Desktop Action OpenAddressBook] +Name=Open address book +Exec=/usr/local/bin/betterbird -addressbook diff --git a/modules/betterbird/files/prefs.json b/modules/betterbird/files/prefs.json new file mode 100644 index 00000000..45fd9732 --- /dev/null +++ b/modules/betterbird/files/prefs.json @@ -0,0 +1,3 @@ +{ + "prefs": {} +} diff --git a/modules/betterbird/files/tags.json b/modules/betterbird/files/tags.json new file mode 100644 index 00000000..4772f099 --- /dev/null +++ b/modules/betterbird/files/tags.json @@ -0,0 +1,3 @@ +{ + "tags": {} +} diff --git a/modules/betterbird/module.json b/modules/betterbird/module.json index 45383ab4..19319ef4 100644 --- a/modules/betterbird/module.json +++ b/modules/betterbird/module.json @@ -33,6 +33,50 @@ "betterbird_drift", "betterbird_reminder_test" ], + "config": { + "prefs": { + "type": "object", + "description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).", + "trusted": true, + "default": {} + }, + "accounts": { + "type": "object", + "description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).", + "trusted": true, + "default": {} + }, + "identities": { + "type": "object", + "description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "calendars": { + "type": "object", + "description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "address-books": { + "type": "object", + "description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "filters": { + "type": "object", + "description": "The message filters of each account, by account: what each matches and what it does with the mail.", + "trusted": true, + "default": {} + }, + "tags": { + "type": "object", + "description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.", + "trusted": true, + "default": {} + } + }, "resources": [ { "id": "configuration-dir", @@ -48,10 +92,10 @@ "owner": "${machine:account}", "mode": "0644", "merge": "json", - "takes-settings": [ + "takes-config": [ "prefs" ], - "content": "{\n \"prefs\": {}\n}\n", + "template": "files/prefs.json", "trusted": true }, { @@ -61,10 +105,10 @@ "owner": "${machine:account}", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "accounts" ], - "content": "{\n \"accounts\": {}\n}\n", + "template": "files/accounts.json", "trusted": true }, { @@ -74,13 +118,13 @@ "owner": "${machine:account}", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "identities", "calendars", "address-books", "filters" ], - "content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n", + "template": "files/configuration.json", "trusted": true }, { @@ -90,11 +134,11 @@ "owner": "${machine:account}", "mode": "0644", "merge": "json", - "takes-settings": [ + "takes-config": [ "tags" ], - "content": "{\n \"tags\": {}\n}\n", - "trusted": false + "template": "files/tags.json", + "trusted": true }, { "id": "community-package", @@ -137,14 +181,14 @@ "type": "file", "path": "/usr/local/bin/betterbird", "mode": "0755", - "content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n" + "template": "files/betterbird" }, { "id": "launcher", "type": "file", "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", "mode": "0644", - "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n" + "template": "files/eu.betterbird.Betterbird.desktop" } ], "build": { @@ -191,6 +235,11 @@ "name": "defaults", "kind": "archive", "from": "install/defaults-pref" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/build-agent/Dockerfile b/modules/build-agent/Dockerfile index 8abeaa19..55f388d9 100644 --- a/modules/build-agent/Dockerfile +++ b/modules/build-agent/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder # Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build # machine's whole job is shelling out to git and docker — it needs a real userland to do that in, diff --git a/modules/build-agent/files/build-agent.env b/modules/build-agent/files/build-agent.env new file mode 100644 index 00000000..15ee1bbb --- /dev/null +++ b/modules/build-agent/files/build-agent.env @@ -0,0 +1,8 @@ +MESH_BROKER_FILE=/run/mesh/broker +MESH_NODE=${machine:name} +MESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port} +MESH_PACKAGE_BINDING=/run/mesh/package-registry.json +MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret +MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json +MESH_WORKSPACE=${dir:workspace} +MESH_JUDGE_CACHE=${dir:judge-cache} diff --git a/modules/build-agent/manifest_test.go b/modules/build-agent/manifest_test.go index 163d2d77..59e041d9 100644 --- a/modules/build-agent/manifest_test.go +++ b/modules/build-agent/manifest_test.go @@ -75,7 +75,10 @@ func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) { t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets) } for _, r := range m.Resources { - content, _ := r["content"].(string) + if r["type"] != "file" { + continue + } + content := templateText(t, r) if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") { if r["only-when-bound"] != "npm-package-registry" { t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"]) @@ -106,7 +109,7 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) { inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/") for _, r := range m.Resources { if r["id"] == "agent-env" { - content, _ := r["content"].(string) + content := templateText(t, r) if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") { t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content) } @@ -115,3 +118,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) { } t.Fatal("the build agent has no environment file") } + +// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a +// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host — +// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE. +func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) { + raw, err := os.ReadFile("module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Data struct { + Own []struct { + ID string `json:"id"` + Path string `json:"path"` + Class string `json:"class"` + } `json:"own"` + } `json:"data"` + Resources []struct { + ID string `json:"id"` + Type string `json:"type"` + Template string `json:"template"` + Volumes []string `json:"volumes"` + } `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + dir, data, env, mounted := false, false, false, false + for _, d := range m.Data.Own { + if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" { + data = true + } + } + for _, r := range m.Resources { + switch { + case r.ID == "judge-cache" && r.Type == "directory": + dir = true + case r.ID == "agent-env": + env = strings.Contains(templateText(t, map[string]any{"id": r.ID, "template": r.Template}), "MESH_JUDGE_CACHE=${dir:judge-cache}\n") + case r.ID == "server": + for _, v := range r.Volumes { + if v == "${dir:judge-cache}:${dir:judge-cache}" { + mounted = true + } + if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") { + t.Errorf("the judge cache is mounted inside the workspace: %s", v) + } + } + } + } + if !dir || !data || !env || !mounted { + t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted) + } +} diff --git a/modules/build-agent/module.json b/modules/build-agent/module.json index 9e2a5b22..b960eacf 100644 --- a/modules/build-agent/module.json +++ b/modules/build-agent/module.json @@ -39,6 +39,12 @@ "path": "${dir:workspace}", "class": "cache", "why": "a build's working copy, cloned again for every build" + }, + { + "id": "judge-cache", + "path": "${dir:judge-cache}", + "class": "cache", + "why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)" } ] }, @@ -54,12 +60,17 @@ "type": "directory", "mode": "0700" }, + { + "id": "judge-cache", + "type": "directory", + "mode": "0700" + }, { "id": "agent-env", "type": "file", "path": "${dir:mesh-state}/build-agent.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n" + "template": "files/build-agent.env" }, { "id": "server", @@ -72,6 +83,7 @@ "volumes": [ "${dir:mesh-state}:/run/mesh:ro", "${dir:workspace}:${dir:workspace}", + "${dir:judge-cache}:${dir:judge-cache}", "/var/run/docker.sock:/var/run/docker.sock" ], "restart-on": [ @@ -103,6 +115,11 @@ "repository": "novox/mesh-controller", "ref": "main" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ], "on": [ diff --git a/modules/build-agent/templates_test.go b/modules/build-agent/templates_test.go new file mode 100644 index 00000000..896a9770 --- /dev/null +++ b/modules/build-agent/templates_test.go @@ -0,0 +1,64 @@ +package buildagent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/ca-trust/files/anchor b/modules/ca-trust/files/anchor new file mode 100755 index 00000000..72576d2f --- /dev/null +++ b/modules/ca-trust/files/anchor @@ -0,0 +1,58 @@ +#!/bin/sh +# The mesh's internal certificate authority, trusted by this machine. +# +# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147). +# Editing it here lasts until the next apply. +# +# There is no prior trust to verify the fetch against — this is the thing that +# establishes it — so it is made over the mesh's own private network, which is +# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the +# route proxy fetch this root for itself). What comes back is checked here: a +# body that is not a certificate is refused now, rather than believed and then +# failed by whatever reads the trust store next. +set -eu + +ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}' +ANCHORS=/etc/ca-certificates/trust-source/anchors +ANCHOR="$ANCHORS/mesh-internal-ca.crt" + +# Arch's layout, said out loud rather than assumed: a machine that keeps its +# anchors elsewhere fails here, visibly, instead of writing a file nothing +# reads. That failure is the signal that this belongs in the host, where one +# operating system's difference lives (novox/hq ADR 0147, option 2). +[ -d "$ANCHORS" ] || { + echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2 + exit 1 +} + +case "${1:-}" in +install) + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + # The authority may still be starting, or this machine may have come up + # before it: two minutes of asking, then an honest failure. + n=0 + while [ "$n" -lt 60 ]; do + if curl --fail --silent --show-error --insecure --max-time 10 \ + --output "$tmp" "$ROOTS" && + grep -q 'BEGIN CERTIFICATE' "$tmp"; then + install -m 0644 "$tmp" "$ANCHOR" + update-ca-trust + exit 0 + fi + n=$((n + 1)) + sleep 2 + done + echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2 + exit 1 + ;; +remove) + # What stopping the unit does, and therefore what being unassigned does. + rm -f "$ANCHOR" + update-ca-trust + ;; +*) + echo "usage: $(basename "$0") install|remove" >&2 + exit 2 + ;; +esac diff --git a/modules/ca-trust/files/mesh-ca-trust.service b/modules/ca-trust/files/mesh-ca-trust.service new file mode 100644 index 00000000..2411560c --- /dev/null +++ b/modules/ca-trust/files/mesh-ca-trust.service @@ -0,0 +1,16 @@ +[Unit] +Description=The mesh's internal certificate authority, trusted by this machine +# novox/hq ADR 0147. Starting this unit places the mesh's root among this +# machine's trust anchors; stopping it takes the root away again, which is what +# the host does when the module is no longer assigned here. +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/anchor install +ExecStop=${dir:state}/anchor remove + +[Install] +WantedBy=multi-user.target diff --git a/modules/ca-trust/module.json b/modules/ca-trust/module.json index 1f7baf81..94e64fe4 100644 --- a/modules/ca-trust/module.json +++ b/modules/ca-trust/module.json @@ -32,14 +32,14 @@ "type": "file", "path": "${dir:state}/anchor", "mode": "0755", - "content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n" + "template": "files/anchor" }, { "id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-ca-trust.service", "mode": "0644", - "content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "files/mesh-ca-trust.service" }, { "id": "trust", @@ -52,5 +52,14 @@ "unit" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 94e65a68..c0423f3d 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -43,7 +43,7 @@ whenever the node's tool runtime collects the module's tools: | file | holds | |---|---| | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | -| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin | +| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one | | `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in | @@ -55,6 +55,17 @@ same content, and never through a directory that is a symbolic link. A placed fi alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's items to report them; nothing else is read or written. +**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge +mints this node's agent a token of its own account, `agent-`, scoped to read repositories alone, and the vault +keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's +credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's +home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it; +and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was +asked) is never placed. While one is held, `managed-settings.json` gives every session's environment +`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file=''`: the +path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed, +printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push. + ## Over NATS Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that diff --git a/modules/claude-code/cmd/claude-code/forge.go b/modules/claude-code/cmd/claude-code/forge.go new file mode 100644 index 00000000..112c033c --- /dev/null +++ b/modules/claude-code/cmd/claude-code/forge.go @@ -0,0 +1,142 @@ +package main + +// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737). +// +// The forge's module mints each node's agent a token of its own account, `agent-`, scoped to read +// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue +// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is +// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR +// 0266: no other module writes there), and points every session's git at it through the managed settings' +// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no +// token is printed, typed on a command line or put in an environment a process listing shows. +// +// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a +// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the +// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not +// been asked yet — is not placed, and what was placed before is taken back. + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" +) + +// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to. +const ( + ForgeProvision = "gitea-read" + forgeSecret = "gitea-read.secret" +) + +func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) } + +// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the +// mesh's own. +func (p Paths) forgeCredentials() string { + return filepath.Join(p.Home, ".config", "mesh", "forge.credentials") +} + +// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token, +// and nothing else. Anything else is refused, said without the value. +func ForgeCredentialLines(raw string) (string, error) { + var lines []string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + u, err := url.Parse(line) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil || + u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " + + "(the forge has not issued it yet, or something else was given)") + } + if pw, ok := u.User.Password(); !ok || pw == "" { + return "", errors.New("a forge credential line names no token") + } + lines = append(lines, line) + } + if len(lines) == 0 { + return "", errors.New("the forge's read credential is empty") + } + return strings.Join(lines, "\n") + "\n", nil +} + +// ForgeCredentialHeld says whether this machine holds a forge read credential to place. +func ForgeCredentialHeld(p Paths) bool { + raw, err := os.ReadFile(p.forgeSecret()) + if err != nil { + return false + } + _, err = ForgeCredentialLines(string(raw)) + return err == nil +} + +// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when +// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value. +func PlaceForgeCredential(p Paths) (string, error) { + path := p.forgeCredentials() + raw, err := os.ReadFile(p.forgeSecret()) + var lines string + var why error + switch { + case errors.Is(err, fs.ErrNotExist): + why = errors.New("this machine holds no forge read credential") + case err != nil: + return "", fmt.Errorf("the forge's read credential cannot be read: %w", err) + default: + lines, why = ForgeCredentialLines(string(raw)) + } + if why != nil { + if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err) + } + return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil + } + if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) { + if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 { + return "", nil + } + } + if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err) + } + tmp := path + ".mesh-tmp" + if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil { + return "", fmt.Errorf("the forge credential could not be written: %w", err) + } + if err := fsys.Chmod(tmp, 0o600); err != nil { + _ = fsys.Remove(tmp) + return "", fmt.Errorf("the forge credential could not be made 0600: %w", err) + } + if err := fsys.Rename(tmp, path); err != nil { + _ = fsys.Remove(tmp) + return "", fmt.Errorf("the forge credential could not be put in place: %w", err) + } + return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil +} + +// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's +// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning. +func forgeEnv(managed map[string]any, credentials string) { + if credentials == "" { + return + } + env := map[string]any{} + if held, ok := managed["env"].(map[string]any); ok { + for k, v := range held { + env[k] = v + } + } + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = "credential.helper" + env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials) + managed["env"] = env +} diff --git a/modules/claude-code/cmd/claude-code/forge_test.go b/modules/claude-code/cmd/claude-code/forge_test.go new file mode 100644 index 00000000..8c504620 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/forge_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/cgi" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const agentToken = "0123456789abcdef0123456789abcdef01234567" + +// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token +// alone, as the forge does for an account a team lets read it. +func privateForge(t *testing.T) (origin, bare string) { + t.Helper() + backend := filepath.Join(gitExecPath(t), "git-http-backend") + if _, err := os.Stat(backend); err != nil { + t.Skipf("git-http-backend is not installed: %v", err) + } + root := t.TempDir() + bare = filepath.Join(root, "private.git") + work := t.TempDir() + runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".") + commit(t, work, "one") + runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare) + cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "agent-anchor" || pass != agentToken { + w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") { + http.Error(w, "read only", http.StatusForbidden) + return + } + cgiHandler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv.URL, bare +} + +func gitExecPath(t *testing.T) string { + t.Helper() + out, err := exec.Command("git", "--exec-path").Output() + if err != nil { + t.Skipf("git is not installed: %v", err) + } + return strings.TrimSpace(string(out)) +} + +func runGit(t *testing.T, dir string, env []string, name string, args ...string) string { + t.Helper() + out, err := runErr(dir, env, name, args...) + if err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, out) + } + return out +} + +func runErr(dir string, env []string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=") + cmd.Env = append(cmd.Env, env...) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func commit(t *testing.T, dir, text string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, dir, nil, "git", "add", "file") + runGit(t, dir, nil, "git", "commit", "-q", "-m", text) +} + +// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git +// configuration of its own. +func sessionEnv(t *testing.T, home, managedSettings string) []string { + t.Helper() + var settings struct { + Env map[string]string `json:"env"` + } + if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil { + t.Fatal(err) + } + env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"} + for k, v := range settings.Env { + env = append(env, k+"="+v) + } + return env +} + +func forgePaths(t *testing.T) Paths { + t.Helper() + return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"} +} + +func managedFor(p Paths) string { + facts := Facts{Node: p.Node, Console: "http://console"} + if ForgeCredentialHeld(p) { + facts.ForgeCredentials = p.forgeCredentials() + } + return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"] +} + +// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and +// fetches it again, and no credential appears in a log, a command line or the session. +func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) { + origin, bare := privateForge(t) + p := forgePaths(t) + repo := origin + "/private.git" + + // Before the forge issued anything, the session's git has no credential: the clone is refused, as today. + before := sessionEnv(t, p.Home, managedFor(p)) + if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil { + t.Fatalf("a private repository was cloned with no credential:\n%s", out) + } + + // The vault hands this module what the forge issued for this machine's agent. + lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" + + "https://agent-anchor:" + agentToken + "@git.example.test\n" + if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + said, err := PlaceForgeCredential(p) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(p.forgeCredentials()) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("the credential file: %v %v, want mode 0600", info, err) + } + if again, err := PlaceForgeCredential(p); err != nil || again != "" { + t.Fatalf("placed again with nothing changed: %q %v", again, err) + } + managed := managedFor(p) + env := sessionEnv(t, p.Home, managed) + + work := t.TempDir() + cloneOut := runGit(t, work, env, "git", "clone", repo, "clone") + clone := filepath.Join(work, "clone") + if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" { + t.Fatalf("cloned %q", got) + } + + // The forge's repository moves on; the agent fetches it again. + other := t.TempDir() + runGit(t, other, nil, "git", "clone", "-q", bare, "w") + commit(t, filepath.Join(other, "w"), "two") + runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main") + fetchOut := runGit(t, clone, env, "git", "fetch", "origin") + if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") { + t.Fatalf("fetched %q", got) + } + + // A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it. + commit(t, clone, "three") + if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil { + t.Fatalf("a push with the agent's credential went through:\n%s", out) + } + + // No credential in what the session saw, its command lines, its environment or the module's words. + remote := runGit(t, clone, env, "git", "remote", "get-url", "origin") + for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut, + "the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"), + "what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} { + if strings.Contains(text, agentToken) { + t.Errorf("the token appears in %s", what) + } + } +} + +func readOrEmpty(path string) string { + raw, _ := os.ReadFile(path) + return string(raw) +} + +func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) { + p := forgePaths(t) + // A value the mesh made while the forge had not been asked: not credential lines, never placed. + if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil { + t.Fatal(err) + } + if said, err := PlaceForgeCredential(p); err != nil || said != "" { + t.Fatalf("%q %v", said, err) + } + if _, err := os.Stat(p.forgeCredentials()); err == nil { + t.Fatal("a value the forge did not issue was placed") + } + if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") { + t.Fatal("the sessions' git is pointed at a credential that is not held") + } + + issued := "https://agent-anchor:" + agentToken + "@git.example.test\n" + if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil { + t.Fatal(err) + } + if _, err := PlaceForgeCredential(p); err != nil { + t.Fatal(err) + } + // git's store helper erased the line after a rejection, and a person's password for another host was stored. + if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil { + t.Fatal(err) + } + if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") { + t.Fatalf("%q %v", said, err) + } + if got := readOrEmpty(p.forgeCredentials()); got != issued { + t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "")) + } + if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", info.Mode().Perm()) + } + + // The credential withdrawn: taken back, and the sessions' git no longer pointed at it. + if err := os.Remove(p.forgeSecret()); err != nil { + t.Fatal(err) + } + if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) { + t.Fatalf("%q %v", said, err) + } + if _, err := os.Stat(p.forgeCredentials()); err == nil { + t.Fatal("a withdrawn credential stayed in the agent's home") + } +} + +func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) { + managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}} + forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials") + env := managed["env"].(map[string]any) + if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" || + env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" { + t.Fatalf("%v", env) + } + none := map[string]any{} + forgeEnv(none, "") + if _, set := none["env"]; set { + t.Fatal("an environment was set with no credential held") + } +} + +func TestForgeCredentialLinesAreReadStrictly(t *testing.T) { + for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test", + "https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test", + "https://agent-anchor:t@git.example.test\nnot a line"} { + if _, err := ForgeCredentialLines(bad); err == nil { + t.Errorf("%q was taken", bad) + } else if strings.Contains(err.Error(), "t@git") { + t.Errorf("the refusal said the value: %v", err) + } + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 618c8145..88250936 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -585,7 +585,25 @@ func run(p Paths, view *ServerView, configView *ConfigView) { report() var last string + // The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed + // it; the sessions' environment names it only while one is held. + forgeHeld := ForgeCredentialHeld(p) + placeForge := func() { + if line, err := PlaceForgeCredential(p); err != nil { + say("%v", err) + } else if line != "" { + say("%s", line) + } + if held := ForgeCredentialHeld(p); held != forgeHeld { + forgeHeld = held + if _, err := RenderNow(p, writeManaged); err != nil { + say("rendering after the forge's read credential changed: %v", err) + } + } + } + placeForge() for range time.Tick(5 * time.Second) { + placeForge() info, err := fsys.Stat(p.credentials()) now := "absent" if err == nil { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 854e6061..12868291 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -216,6 +216,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { if !readJSON(p.Facts, &facts) || facts.Console == "" { return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts) } + if ForgeCredentialHeld(p) { + facts.ForgeCredentials = p.forgeCredentials() + } var settings Settings readJSON(p.Settings, &settings) var binding *Binding diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 80fb011c..0ce3acde 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -34,6 +34,9 @@ const meshEntry = "mesh" type Facts struct { Node string `json:"node"` Console string `json:"console"` + // ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one + // (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh. + ForgeCredentials string `json:"-"` } // Settings are the operator's, for the mesh or this node. @@ -139,6 +142,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath } managed[key] = entries } + forgeEnv(managed, facts.ForgeCredentials) if binding != nil && binding.Kind == "api-key" { managed["apiKeyHelper"] = helperPath } diff --git a/modules/claude-code/cmd/claude-code/shell_test.go b/modules/claude-code/cmd/claude-code/shell_test.go index 9ce22880..3061a3b2 100644 --- a/modules/claude-code/cmd/claude-code/shell_test.go +++ b/modules/claude-code/cmd/claude-code/shell_test.go @@ -19,11 +19,12 @@ const agentAccountFile = "/etc/claude-code/agent-account" type manifestShell struct { Resources []map[string]any `json:"resources"` - Shell []struct { - For string `json:"for"` - Slot string `json:"slot"` - Code string `json:"code"` - } `json:"shell"` + // The zsh code is an item of the collection shell:zsh: (novox/hq ADR 0349 clause 13), its text the + // template file beside the manifest. + Items []struct { + Collection string `json:"collection"` + Template string `json:"template"` + } `json:"items"` } func readManifestShell(t *testing.T) manifestShell { @@ -54,9 +55,13 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any { func zshCode(t *testing.T, m manifestShell) string { t.Helper() var code []string - for _, s := range m.Shell { - if s.For == "zsh" { - code = append(code, s.Code) + for _, it := range m.Items { + if strings.HasPrefix(it.Collection, "shell:zsh:") { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + code = append(code, string(text)) } } if len(code) != 1 { @@ -71,8 +76,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) { if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" { t.Fatalf("the account's file is %v", r) } - if r["content"] != "${machine:agent-account}\n" { - t.Fatalf("the account's file holds %q", r["content"]) + if text := templateText(t, r); text != "${machine:agent-account}\n" { + t.Fatalf("the account's file holds %q", text) } if _, has := r["owner"]; has { t.Fatal("the account's file must be root's, as claude-agent is") @@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) { if err != nil { t.Fatal(err) } - start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string) + start := templateText(t, resourceByID(t, readManifestShell(t), "start")) if !strings.Contains(start, "exec claude \"$@\"") { t.Fatalf("claude-agent no longer runs claude itself:\n%s", start) } diff --git a/modules/claude-code/cmd/claude-code/templates_test.go b/modules/claude-code/cmd/claude-code/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/claude-code/files/agent-account b/modules/claude-code/files/agent-account new file mode 100644 index 00000000..b081a6c3 --- /dev/null +++ b/modules/claude-code/files/agent-account @@ -0,0 +1 @@ +${machine:agent-account} diff --git a/modules/claude-code/files/claude-agent b/modules/claude-code/files/claude-agent new file mode 100755 index 00000000..e3d330f2 --- /dev/null +++ b/modules/claude-code/files/claude-agent @@ -0,0 +1,13 @@ +#!/bin/sh +# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this +# machine. Written whole at every push: an edit here is overwritten. +agent='${machine:agent-account}' +if [ "$(id -un)" = "$agent" ]; then + exec claude "$@" +fi +# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The +# operator's override of the guard travels only when it is set in this shell, as it would reach claude. +if [ -n "$MESH_GUARD_OVERRIDE" ]; then + exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@" +fi +exec sudo -iu "$agent" claude "$@" diff --git a/modules/claude-code/files/facts.json b/modules/claude-code/files/facts.json new file mode 100644 index 00000000..7fd72bd4 --- /dev/null +++ b/modules/claude-code/files/facts.json @@ -0,0 +1,4 @@ +{ + "node": "${machine:name}", + "console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp" +} diff --git a/modules/claude-code/files/settings.json b/modules/claude-code/files/settings.json new file mode 100644 index 00000000..8284e660 --- /dev/null +++ b/modules/claude-code/files/settings.json @@ -0,0 +1,10 @@ +{ + "role": "", + "mcp_servers": {}, + "managed_settings": {}, + "instructions": {}, + "output_styles": {}, + "skills": {}, + "commands": {}, + "agents": {} +} diff --git a/modules/claude-code/files/zsh-normal b/modules/claude-code/files/zsh-normal new file mode 100644 index 00000000..b90b85f9 --- /dev/null +++ b/modules/claude-code/files/zsh-normal @@ -0,0 +1,12 @@ +# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the +# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where +# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs +# the real claude: exec, sudo and its sh see no alias. +if [[ -r /etc/claude-code/agent-account ]]; then + () { + local agent=$(/clipmenu../. majorVersion = 6 ) @@ -71,15 +70,15 @@ func cksum(data []byte) string { func entryID(line string) string { return cksum([]byte(line + "\n")) } -// Entry is one clip in the history. +// Entry is one clip in the history as a caller sees it: when and how long, never what. The text of a +// clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the +// operator's own screen through clipmenu (issue 750). type Entry struct { - ID string `json:"id"` - Line string `json:"line"` - At string `json:"at"` - Bytes int `json:"bytes"` - Text string `json:"text,omitempty"` - Truncated bool `json:"truncated,omitempty"` - at int64 + At string `json:"at"` + Bytes int `json:"bytes"` + id string + line string + at int64 } // HistoryResult is what node-clipboard.history answers. @@ -119,14 +118,15 @@ func readStore(dir string) ([]Entry, error) { } out := make([]Entry, 0, len(latest)) for line, ns := range latest { - out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)}) + out = append(out, Entry{id: entryID(line), line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)}) } sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at }) return out, scan.Err() } -// History is the clipboard's history, newest first. -func History(limit, maxBytes int) (HistoryResult, error) { +// History is the clipboard's history, newest first: how many entries, when each was copied and how +// long each is, and whether the daemon is collecting. It never returns a copied text. +func History(limit int) (HistoryResult, error) { dir, err := storeDir(findEnvironment()) if err != nil { return HistoryResult{}, err @@ -140,15 +140,8 @@ func History(limit, maxBytes int) (HistoryResult, error) { if i == limit { break } - if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil { + if info, err := os.Stat(filepath.Join(dir, e.id)); err == nil { e.Bytes = int(info.Size()) - if maxBytes > 0 { - raw, _ := os.ReadFile(filepath.Join(dir, e.ID)) - if len(raw) > maxBytes { - raw, e.Truncated = raw[:maxBytes], true - } - e.Text = string(raw) - } } out.Entries = append(out.Entries, e) } @@ -159,6 +152,40 @@ func History(limit, maxBytes int) (HistoryResult, error) { type CopyResult struct { Bytes int `json:"bytes"` Unit string `json:"unit"` + // Verified says the clipboard was read back and held exactly the text. + Verified bool `json:"verified"` +} + +// readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times. +var readBackWait = 200 * time.Millisecond + +// readBack waits for the clipboard to hold text and answers why when it does not. +func readBack(s Session, unit, text string) error { + var last string + for i := 0; i < 15; i++ { + time.Sleep(readBackWait) + r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard") + switch { + case err != nil: + last = err.Error() + case r.Code != 0: + last = "xsel: " + strings.TrimSpace(r.Stderr) + case r.Stdout == text: + return nil + default: + last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout)) + } + } + why := "" + if s.Display == "" { + why = "; the session has no DISPLAY, so the owner could not reach the screen" + } + if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil { + if tail := strings.TrimSpace(j.Stdout); tail != "" { + why += "; the owner said: " + tail + } + } + return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why) } // Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the @@ -194,17 +221,20 @@ func Copy(text string) (CopyResult, error) { os.Remove(f.Name()) return CopyResult{}, err } - return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil + if err := readBack(s, unit, text); err != nil { + return CopyResult{}, err + } + return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil } -// PasteResult is what clipmenu_paste answers. +// PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The +// clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750). type PasteResult struct { - Text string `json:"text"` - Bytes int `json:"bytes"` - Truncated bool `json:"truncated,omitempty"` + Bytes int `json:"bytes"` + Text bool `json:"holds_text"` } -// Paste reads the clipboard now. +// Paste reads the clipboard now and says how long it is. func Paste() (PasteResult, error) { s, err := findSession() if err != nil { @@ -217,11 +247,7 @@ func Paste() (PasteResult, error) { if r.Code != 0 { return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr)) } - out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated} - if len(out.Text) > mostPaste { - out.Text, out.Truncated = out.Text[:mostPaste], true - } - return out, nil + return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, nil } // ChangeResult is what clear and delete answer. @@ -298,10 +324,12 @@ func Clear() (ChangeResult, error) { return out, err } -// Delete forgets one entry, by id or by its first line. -func Delete(id, line string) (ChangeResult, error) { - if (id == "") == (line == "") { - return ChangeResult{}, errors.New("give the entry's id or its line, one of the two") +// Delete forgets one entry, by its place in the history as node-clipboard.history lists it (1 is the +// newest) together with the time it reports, so a copy arriving since the listing cannot shift the +// place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line. +func Delete(place int, at string) (ChangeResult, error) { + if place < 1 { + return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest") } dir, err := storeOf() if err != nil { @@ -309,6 +337,17 @@ func Delete(id, line string) (ChangeResult, error) { } var out ChangeResult err = withStoreLock(dir, func() error { + entries, err := readStore(dir) + if err != nil { + return err + } + if place > len(entries) { + return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries)) + } + if entries[place-1].At != at { + return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at) + } + line := entries[place-1].line raw, err := os.ReadFile(filepath.Join(dir, "line_cache")) if err != nil && !errors.Is(err, os.ErrNotExist) { return err @@ -319,7 +358,7 @@ func Delete(id, line string) (ChangeResult, error) { continue } _, text, _ := strings.Cut(l, " ") - if text == line || (id != "" && entryID(text) == id) { + if text == line { out.Removed++ _ = os.Remove(filepath.Join(dir, entryID(text))) continue @@ -327,7 +366,7 @@ func Delete(id, line string) (ChangeResult, error) { kept = append(kept, l) } if out.Removed == 0 { - return fmt.Errorf("no entry %s%s in the clipboard history", id, line) + return fmt.Errorf("no entry %d in the clipboard history", place) } content := strings.Join(kept, "\n") if content != "" { diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go index 058b8ae9..2be67364 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "os" "os/exec" @@ -51,46 +52,84 @@ func store(t *testing.T, clips map[string]string, order ...string) string { return dir } -func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) { - store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first") +func TestTheHistoryAnswersCountTimesAndLengthsNeverContent(t *testing.T) { + store(t, map[string]string{"first": "first", "sk-ant-api03-SECRETKEY (2 lines)": "sk-ant-api03-SECRETKEY\nline two"}, "first", "sk-ant-api03-SECRETKEY (2 lines)", "first") fakeProcess(t, nobody, "clipmenud") - h, err := History(10, 4096) + h, err := History(10) if err != nil { t.Fatal(err) } - if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 { + if !h.Collecting || h.Total != 2 || len(h.Entries) != 2 || h.Entries[0].Bytes != 5 || h.Entries[1].Bytes != 31 || h.Entries[0].At == "" { t.Fatalf("%+v", h) } - if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated { - t.Fatalf("limited and cut: %+v", h) + raw, _ := json.Marshal(h) + for _, leak := range []string{"first", "SECRETKEY", "line two"} { + if strings.Contains(string(raw), leak) { + t.Fatalf("the history answers %q: %s", leak, raw) + } } - if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 { - t.Fatalf("without text: %+v", h) + if h, _ := History(1); len(h.Entries) != 1 { + t.Fatalf("limited: %+v", h) } } -func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) { +// at is the time History reports for the entry at place. +func at(t *testing.T, place int) string { + t.Helper() + h, err := History(100) + if err != nil || place > len(h.Entries) { + t.Fatalf("%+v, %v", h, err) + } + return h.Entries[place-1].At +} + +func TestADeleteWhoseListingIsStaleIsRefused(t *testing.T) { + dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b") + listed := at(t, 1) // b + f, _ := os.OpenFile(filepath.Join(dir, "line_cache"), os.O_APPEND|os.O_WRONLY, 0o600) + f.WriteString("1800000000000000000 a\n") // a copy arrives: place 1 is now a + f.Close() + if _, err := Delete(1, listed); err == nil || !strings.Contains(err.Error(), "list it again") { + t.Fatalf("the wrong entry was deleted: %v", err) + } + if h, _ := History(10); h.Total != 2 { + t.Fatalf("%+v", h) + } +} + +func TestAnEntryIsDeletedByItsPlaceWithItsTextAndNeverByItsText(t *testing.T) { dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a") - r, err := Delete(entryID("a"), "") + // newest first: a, c, b + r, err := Delete(1, at(t, 1)) if err != nil || r.Removed != 2 || r.Remaining != 2 { - t.Fatalf("by id, both copies: %+v, %v", r, err) + t.Fatalf("by place, both copies: %+v, %v", r, err) } if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) { t.Fatal("the text stayed") } - if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 { - t.Fatalf("by line: %+v, %v", r, err) + if r, err := Delete(2, at(t, 2)); err != nil || r.Removed != 1 || r.Remaining != 1 { + t.Fatalf("second place (b): %+v, %v", r, err) } - if _, err := Delete("", "zzz"); err == nil { - t.Fatal("a missing entry was reported deleted") + if _, err := Delete(5, "x"); err == nil { + t.Fatal("a missing place was reported deleted") } - if _, err := Delete("x", "y"); err == nil { - t.Fatal("both an id and a line were accepted") + if _, err := Delete(0, "x"); err == nil { + t.Fatal("place 0 was accepted") } cache, _ := os.ReadFile(filepath.Join(dir, "line_cache")) if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 { t.Fatalf("line cache: %q", cache) } + for _, tool := range tools() { + if tool.Name == "clipmenu_delete" { + if _, ok := tool.Input["line"]; ok { + t.Fatal("delete still takes a line") + } + if _, ok := tool.Input["id"]; ok { + t.Fatal("delete still takes an id") + } + } + } } func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) { @@ -112,13 +151,21 @@ func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) { } } -func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) { +func copyBed(t *testing.T, xsel string) string { + t.Helper() store(t, nil) fakeProcess(t, nobody, "i3", "DISPLAY=:1") - bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`}) + bin := fakeBinaries(t, map[string]string{"systemctl": "true", "xsel": xsel, + "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`}) t.Setenv("LOG", filepath.Join(bin, "log")) + readBackWait = 0 + return bin +} + +func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManagerAndReadsItBack(t *testing.T) { + bin := copyBed(t, `cat "$LOG.text"`) r, err := Copy("secret-free text") - if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") { + if err != nil || r.Bytes != 16 || !r.Verified || !strings.HasPrefix(r.Unit, "clipmenu-copy-") { t.Fatalf("%+v, %v", r, err) } asked, _ := os.ReadFile(filepath.Join(bin, "log")) @@ -134,7 +181,23 @@ func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) { } } -func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) { +func TestCopyFailsWhenTheClipboardHoldsSomethingElse(t *testing.T) { + copyBed(t, `printf 'old contents'`) + _, err := Copy("new text") + if err == nil || !strings.Contains(err.Error(), "does not hold") || strings.Contains(err.Error(), "old contents") { + t.Fatalf("a copy that did not land was reported: %v", err) + } +} + +func TestCopyFailsSayingWhyWhenTheOwnerCannotReachTheDisplay(t *testing.T) { + copyBed(t, `echo "xsel: Can't open display: (null)" >&2; exit 1`) + _, err := Copy("new text") + if err == nil || !strings.Contains(err.Error(), "Can't open display") { + t.Fatalf("the reason was not given: %v", err) + } +} + +func TestPasteSaysHowMuchTheClipboardHoldsNeverWhat(t *testing.T) { fakeMachine(t) if _, err := Paste(); !errors.Is(err, ErrNoSession) { t.Fatal(err) @@ -142,8 +205,9 @@ func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) { fakeProcess(t, nobody, "i3", "DISPLAY=:1") fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`}) p, err := Paste() - if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 { - t.Fatalf("%+v, %v", p, err) + raw, _ := json.Marshal(p) + if err != nil || p.Bytes != 16 || strings.Contains(string(raw), "on the clipboard") { + t.Fatalf("%s, %v", raw, err) } } @@ -157,7 +221,26 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) { if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") { t.Fatal(err) } - if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting { + if h, err := History(5); err != nil || h.Total != 0 || h.Collecting { t.Fatalf("an empty history: %+v, %v", h, err) } } + +// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud +// (issue 860: collecting was false while the daemon ran). +func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) { + store(t, map[string]string{"first": "first"}, "first") + fakeProcess(t, nobody, "bash") + cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline") + if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil { + t.Fatal(err) + } + if h, err := History(10); err != nil || !h.Collecting { + t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err) + } + // another bash script is not the daemon + os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644) + if h, _ := History(10); h.Collecting { + t.Fatalf("a bash script that is not clipmenud counted: %+v", h) + } +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/main.go b/modules/clipmenu/cmd/clipmenu-tools/main.go index e8e92129..822b99e7 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/main.go +++ b/modules/clipmenu/cmd/clipmenu-tools/main.go @@ -22,28 +22,23 @@ func tools() []stdio.Tool { return []stdio.Tool{ { Name: "node-clipboard.history", - Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " + - "and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.", + Description: "What the operator copied, newest first: how many entries, when each was copied and how long " + + "it is, never what it says. Whether the clipboard daemon is collecting. The text is read on the operator's own screen, through clipmenu.", Input: map[string]any{ - "limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"}, - "max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"}, + "limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"}, }, Run: func(args map[string]any) (any, error) { limit, err := whole(args, "limit", 20, 1, 500) if err != nil { return nil, err } - most, err := whole(args, "max_bytes", 4096, 0, 65536) - if err != nil { - return nil, err - } - return History(limit, most) + return History(limit) }, }, { Name: "node-clipboard.copy", Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " + - "like any copy. Answers how many bytes.", + "like any copy. Reads the clipboard back and answers an error with the reason when it does not hold the text.", Input: map[string]any{ "type": "object", "properties": map[string]any{ @@ -61,7 +56,7 @@ func tools() []stdio.Tool { }, { Name: "clipmenu_paste", - Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).", + Description: "How many bytes of text the operator's clipboard holds right now, never the text itself.", Run: func(map[string]any) (any, error) { return Paste() }, }, { @@ -71,19 +66,22 @@ func tools() []stdio.Tool { }, { Name: "clipmenu_delete", - Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " + - "its first line exactly.", + Description: "Forget one entry of the clipboard history, by its place in node-clipboard.history's list " + + "(1 is the newest) and its time as the history gives it; refused when the two no longer match.", Input: map[string]any{ - "id": map[string]any{"type": "string", "description": "the entry's id"}, - "line": map[string]any{"type": "string", "description": "the entry's first line, exactly"}, + "place": map[string]any{"type": "integer", "description": "the entry's place in the history, 1 for the newest"}, + "at": map[string]any{"type": "string", "description": "the entry's time, as the history reports it"}, }, Run: func(args map[string]any) (any, error) { - id, err := text(args, "id", false) + place, err := whole(args, "place", 0, 0, 100000) if err != nil { return nil, err } - line, _ := args["line"].(string) - return Delete(id, line) + at, err := text(args, "at", true) + if err != nil { + return nil, err + } + return Delete(place, at) }, }, } diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go index 441415f9..28adb660 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go @@ -14,6 +14,8 @@ import ( type manifest struct { Module string `json:"module"` Version string `json:"version"` + Moved bool `json:"moved"` + Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` @@ -23,7 +25,7 @@ type manifest struct { Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. - Contributions []contribution `json:"contributions"` + Contributions []any `json:"contributions"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` @@ -46,6 +48,53 @@ type shellCode struct { Code string `json:"code"` } +// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} + +// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of +// the collections shell::, in the order they are declared. +func (m manifest) shell(t *testing.T) []shellCode { + t.Helper() + var out []shellCode + for _, it := range m.Items { + parts := strings.Split(it.Collection, ":") + if len(parts) != 3 || parts[0] != "shell" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) + } + return out +} + +// environmentOf is the variables the module sets, read from the templates of its items of the environment. +func (m manifest) environmentOf(t *testing.T) *environment { + t.Helper() + var env *environment + for _, it := range m.Items { + if it.Collection != "environment" || it.Attributes["kind"] != "variable" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + if env == nil { + env = &environment{Variables: map[string]string{}} + } + env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + return env +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -58,6 +107,7 @@ func readManifest(t *testing.T) manifest { if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } + m.Environment = m.environmentOf(t) return m } @@ -85,26 +135,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { @@ -176,31 +266,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } -type contribution struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` -} +// displaySession is the collection the module's lines for the window manager are an item of. +const displaySession = "contribution:node-display-session:config" -// i3Lines is what the module contributes to the window manager. +// i3Lines is what the module contributes to the window manager: the text of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" && c.Kind == "config" { - out += c.Content + for _, it := range m.Items { + if it.Collection == displaySession { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + out += string(text) } } return out } -// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no +// old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) + if len(m.Contributions) != 0 { + t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } - if m.i3Lines() != string(want) { - t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + var templates []string + for _, it := range m.Items { + if it.Collection == displaySession { + templates = append(templates, it.Template) + } + } + if len(templates) != 1 || templates[0] != source { + t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) + } + if m.i3Lines() == "" { + t.Fatalf("the template %s holds no line", source) } } diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go index 3ab86569..4e769c4c 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go @@ -1,9 +1,16 @@ package main import ( + "context" + "net" + "os" + "os/exec" "reflect" "strings" + "sync" + "syscall" "testing" + "time" ) // clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history @@ -27,20 +34,48 @@ func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) { } } -func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) { +func TestTheDaemonIsTheOperatorsUserUnitEnabledAndRunning(t *testing.T) { m := readManifest(t) - if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" { - t.Fatalf("%+v", m.Shell) + if len(m.shell(t)) != 0 { + t.Fatalf("a second start from the session: %+v", m.shell(t)) } - code := m.Shell[0].Code - // Started once, through the module's text-only xsel (a selection holding an image is never read). - if strings.Count(code, "clipmenud &\n") != 1 || !strings.Contains(code, `PATH="/usr/local/lib/mesh-clipmenu:$PATH" clipmenud &`) || - strings.Contains(code, "greenclip") { - t.Fatalf("%q", code) + svc := m.resource(t, "daemon") + want := map[string]any{"id": "daemon", "type": "service", "unit": "clipmenud.service", "scope": "user", + "user": "${machine:account}", "state": "running", "boot": "enabled", "reload-on": nil} + for k, v := range want { + if k != "reload-on" && svc[k] != v { + t.Fatalf("daemon %s = %v, want %v: %v", k, svc[k], v, svc) + } } - for _, r := range m.Resources { - if r["type"] == "service" || r["type"] == "process" { - t.Fatalf("a second start: %v", r) + // ADR 0240 rule 8: a long-running resource says how it is ready. The launcher keeps the unit active while + // it waits for the display and execs clipmenud, so the unit's state is the daemon's own liveness. + if h, _ := svc["health"].(map[string]any); h["kind"] != "unit" { + t.Fatalf("daemon health = %v, want kind unit", svc["health"]) + } + dropin := m.resource(t, "daemon-dropin") + content := m.text(t, "daemon-dropin") + if dropin["path"] != "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf" || + dropin["owner"] != "${machine:account}" || + !strings.Contains(content, "ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session") || + strings.Contains(content, "greenclip") { + t.Fatalf("drop-in: %v", dropin) + } + launcher := m.text(t, "launcher") + if !strings.Contains(launcher, "DISPLAY") || !strings.Contains(launcher, "/tmp/.X11-unix") || + !strings.Contains(launcher, `PATH="/usr/local/lib/mesh-clipmenu:$PATH"`) || !strings.Contains(launcher, "exec clipmenud") { + t.Fatalf("launcher: %q", launcher) + } + // Waits for the display instead of exiting into the start limit; DISPLAY from the manager wins. + if !strings.Contains(launcher, "until [ -n \"$DISPLAY\" ]") || !strings.Contains(launcher, "sleep 1") || + strings.Contains(launcher, "exit 1") || strings.Contains(launcher, "exit 0") { + t.Fatalf("the launcher must wait, not exit: %q", launcher) + } + if !strings.Contains(content, "StartLimitIntervalSec=0") || !strings.Contains(content, "RestartSec=2s") { + t.Fatalf("drop-in lacks the safety net: %q", content) + } + for k, v := range m.environmentOf(t).Variables { + if !strings.Contains(content, "Environment="+k+"="+v) { + t.Fatalf("the unit lacks %s=%s", k, v) } } } @@ -64,3 +99,131 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} + +// Run for real: DISPLAY from the manager is used at once; several sockets and no DISPLAY wait. +func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) { + m := readManifest(t) + script := m.text(t, "launcher") + dir := t.TempDir() + file := dir + "/launcher" + // clipmenud and the socket directory are faked: the script's own paths are rewritten. + body := strings.ReplaceAll(script, "/tmp/.X11-unix", dir+"/x11") + body = strings.Replace(body, "exec clipmenud", `echo "DISPLAY=$DISPLAY"`, 1) + if err := os.WriteFile(file, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + os.MkdirAll(dir+"/x11", 0o755) + fakeBin := dir + "/bin" + os.MkdirAll(fakeBin, 0o755) + // the manager's environment: empty until the file appears + os.WriteFile(fakeBin+"/systemctl", []byte("#!/bin/sh\n[ -f \""+dir+"/env\" ] && cat \""+dir+"/env\"\nexit 0\n"), 0o755) + run := func(env ...string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", file) + cmd.Env = append([]string{"PATH=" + fakeBin + ":" + os.Getenv("PATH"), "HOME=" + dir}, env...) + out, _ := cmd.Output() + return strings.TrimSpace(string(out)), ctx.Err() == nil + } + if out, ok := run("DISPLAY=:7"); !ok || out != "DISPLAY=:7" { + t.Fatalf("with DISPLAY: %q %v", out, ok) + } + if _, ok := run(); ok { + t.Fatal("it exited with no X server") + } + for _, n := range []string{"X0", "X1"} { + l, _ := net.Listen("unix", dir+"/x11/"+n) + defer l.Close() + } + if _, ok := run(); ok { + t.Fatal("it chose one of two servers") + } + // the session hands the manager its display while the launcher waits + go func() { + time.Sleep(1200 * time.Millisecond) + os.WriteFile(dir+"/env", []byte("DISPLAY=:1\nOTHER=x\n"), 0o644) + }() + if out, ok := run(); !ok || out != "DISPLAY=:1" { + t.Fatalf("the display that arrived while waiting: %q %v", out, ok) + } +} + +// Run for real: while another clipmenud of the account holds the session lock the launcher waits without +// a word, and starts clipmenud once the lock is free (issue 862). +func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) { + if _, err := exec.LookPath("flock"); err != nil { + t.Skip("flock is missing here") + } + m := readManifest(t) + script := m.text(t, "launcher") + dir := t.TempDir() + body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1) + file := dir + "/launcher" + if err := os.WriteFile(file, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + store := dir + "/cm/clipmenu.6.op" + os.MkdirAll(store, 0o700) + lock := store + "/session_lock" + // the older clipmenud: it holds the lock the way clipmenud does, until it is killed + holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60") + holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := holder.Start(); err != nil { + t.Fatal(err) + } + // flock's child shares the lock, so the whole group goes + release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() } + defer release() + for i := 0; i < 50; i++ { + if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil { + break + } + time.Sleep(50 * time.Millisecond) + } + var out syncBuffer + cmd := exec.Command("sh", file) + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"} + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + t.Fatalf("the launcher went on while the lock was held: %q", out.String()) + case <-time.After(2500 * time.Millisecond): + } + if out.Len() != 0 { + t.Fatalf("the launcher spoke while it waited: %q", out.String()) + } + release() + select { + case <-done: + case <-time.After(5 * time.Second): + cmd.Process.Kill() + t.Fatal("the launcher did not start clipmenud after the lock was freed") + } + if got := strings.TrimSpace(out.String()); got != "started" { + t.Fatalf("after the lock was freed: %q", got) + } +} + +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() } +func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() } diff --git a/modules/clipmenu/cmd/clipmenu-tools/session.go b/modules/clipmenu/cmd/clipmenu-tools/session.go index dc21774a..55d66b2b 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/session.go +++ b/modules/clipmenu/cmd/clipmenu-tools/session.go @@ -403,7 +403,7 @@ func processesOf(comm string) []int { if owner, ok := ownerOf(dir); !ok || owner != uid { continue } - if readTrimmed(filepath.Join(dir, "comm")) == comm { + if readTrimmed(filepath.Join(dir, "comm")) == comm || runsScript(dir, comm) { pids = append(pids, pid) } } @@ -411,6 +411,24 @@ func processesOf(comm string) []int { return pids } +// runsScript is whether the process is an interpreter running the script named name: clipmenud is a +// bash script, so its comm is "bash" and only its command line, "bash /usr/bin/clipmenud", names it. +func runsScript(procDir, name string) bool { + raw, err := os.ReadFile(filepath.Join(procDir, "cmdline")) + if err != nil { + return false + } + args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + if len(args) < 2 { + return false + } + switch filepath.Base(args[0]) { + case "bash", "sh", "dash", "zsh", "env": + return filepath.Base(args[1]) == name + } + return false +} + // signalAll sends sig to every process of this uid named comm, and answers the pids it reached. func signalAll(comm string, sig syscall.Signal) []int { var reached []int diff --git a/modules/clipmenu/files/bin/clipmenud-session b/modules/clipmenu/files/bin/clipmenud-session new file mode 100644 index 00000000..5bd40ee2 --- /dev/null +++ b/modules/clipmenu/files/bin/clipmenud-session @@ -0,0 +1,41 @@ +#!/bin/sh +# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push. +# +# The account's service manager starts before the X server does, and has a DISPLAY only once the session's +# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits +# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when +# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a +# second server beside the session) wait for the session's own to arrive. +# A running process does not see what the session hands the manager later, so each pass asks the manager. +until [ -n "$DISPLAY" ]; do + for pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do + export "$pair" + done + [ -n "$DISPLAY" ] && break + set -- /tmp/.X11-unix/X* + if [ $# -eq 1 ] && [ -S "$1" ]; then + DISPLAY=":${1##*/X}" + export DISPLAY + else + sleep 1 + fi +done +if [ -z "$XAUTHORITY" ] && [ -r "$HOME/.Xauthority" ]; then + XAUTHORITY="$HOME/.Xauthority" + export XAUTHORITY +fi +# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits +# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from +# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly, +# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule) +# and lets go at once. Exit status 1 is "held" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own +# error. Another daemon winning the gap between look and start costs one restart, not a loop. +lock="${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock" +while [ -e "$lock" ]; do + flock -n "$lock" true + [ $? -eq 1 ] || break + sleep 2 +done +PATH="/usr/local/lib/mesh-clipmenu:$PATH" +export PATH +exec clipmenud diff --git a/modules/clipmenu/files/bin/xsel b/modules/clipmenu/files/bin/xsel new file mode 100755 index 00000000..10f9bbd9 --- /dev/null +++ b/modules/clipmenu/files/bin/xsel @@ -0,0 +1,20 @@ +#!/bin/sh +# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push. +# +# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an +# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes, +# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is +# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that +# asks on its main thread freezes. So a read goes ahead only when the selection offers text. +case " $* " in +*" -o "* | *" --output "*) + selection=clipboard + case " $* " in + *" --primary "* | *" -p "*) selection=primary ;; + *" --secondary "* | *" -s "*) selection=secondary ;; + esac + targets=$(timeout 1 xclip -selection "$selection" -t TARGETS -o 2>/dev/null) || exit 1 + printf '%s\n' "$targets" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1 + ;; +esac +exec /usr/bin/xsel "$@" diff --git a/modules/clipmenu/files/env-cm-histlength b/modules/clipmenu/files/env-cm-histlength new file mode 100644 index 00000000..60d3b2f4 --- /dev/null +++ b/modules/clipmenu/files/env-cm-histlength @@ -0,0 +1 @@ +15 diff --git a/modules/clipmenu/files/env-cm-max-clips b/modules/clipmenu/files/env-cm-max-clips new file mode 100644 index 00000000..1b79f38e --- /dev/null +++ b/modules/clipmenu/files/env-cm-max-clips @@ -0,0 +1 @@ +500 diff --git a/modules/clipmenu/files/env-cm-selections b/modules/clipmenu/files/env-cm-selections new file mode 100644 index 00000000..613d81a1 --- /dev/null +++ b/modules/clipmenu/files/env-cm-selections @@ -0,0 +1 @@ +clipboard diff --git a/modules/clipmenu/files/systemd/clipmenud-mesh.conf b/modules/clipmenu/files/systemd/clipmenud-mesh.conf new file mode 100644 index 00000000..799e22d3 --- /dev/null +++ b/modules/clipmenu/files/systemd/clipmenud-mesh.conf @@ -0,0 +1,14 @@ +# clipmenud in the operator's account (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# The package's unit starts clipmenud with no DISPLAY; the module's launcher finds the display and puts the +# text-only xsel first on the PATH. The settings repeat the module's environment, which the account's +# service manager does not read. +[Unit] +StartLimitIntervalSec=0 + +[Service] +RestartSec=2s +Environment=CM_SELECTIONS=clipboard +Environment=CM_MAX_CLIPS=500 +Environment=CM_HISTLENGTH=15 +ExecStart= +ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json index e157350c..f2d328f2 100644 --- a/modules/clipmenu/module.json +++ b/modules/clipmenu/module.json @@ -1,6 +1,7 @@ { "module": "clipmenu", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -22,18 +23,38 @@ "clipmenu_clear", "clipmenu_delete" ], - "environment": { - "variables": { - "CM_SELECTIONS": "clipboard", - "CM_MAX_CLIPS": "500", - "CM_HISTLENGTH": "15" - } - }, - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\n# Through the module's xsel, which reads only a selection offering text (see the README).\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\" clipmenud &\n" + "collection": "environment", + "name": "cm-selections", + "attributes": { + "kind": "variable", + "name": "CM_SELECTIONS" + }, + "template": "files/env-cm-selections" + }, + { + "collection": "environment", + "name": "cm-max-clips", + "attributes": { + "kind": "variable", + "name": "CM_MAX_CLIPS" + }, + "template": "files/env-cm-max-clips" + }, + { + "collection": "environment", + "name": "cm-histlength", + "attributes": { + "kind": "variable", + "name": "CM_HISTLENGTH" + }, + "template": "files/env-cm-histlength" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-clipmenu.conf" } ], "resources": [ @@ -53,7 +74,48 @@ "type": "file", "path": "/usr/local/lib/mesh-clipmenu/xsel", "mode": "0755", - "content": "#!/bin/sh\n# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an\n# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes,\n# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is\n# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that\n# asks on its main thread freezes. So a read goes ahead only when the selection offers text.\ncase \" $* \" in\n*\" -o \"* | *\" --output \"*)\n\tselection=clipboard\n\tcase \" $* \" in\n\t*\" --primary \"* | *\" -p \"*) selection=primary ;;\n\t*\" --secondary \"* | *\" -s \"*) selection=secondary ;;\n\tesac\n\ttargets=$(timeout 1 xclip -selection \"$selection\" -t TARGETS -o 2>/dev/null) || exit 1\n\tprintf '%s\\n' \"$targets\" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1\n\t;;\nesac\nexec /usr/bin/xsel \"$@\"\n" + "template": "files/bin/xsel" + }, + { + "id": "user-units-dir", + "type": "directory", + "path": "${machine:account-home}/.config/systemd/user", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "daemon-dropins", + "type": "directory", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "daemon-dropin", + "type": "file", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/systemd/clipmenud-mesh.conf" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", + "mode": "0755", + "template": "files/bin/clipmenud-session" + }, + { + "id": "daemon", + "type": "service", + "unit": "clipmenud.service", + "scope": "user", + "user": "${machine:account}", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } } ], "build": { @@ -68,14 +130,12 @@ "loads": [ "clipmenu-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n" - } - ] + } } diff --git a/modules/confluence/files/config.json b/modules/confluence/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/modules/confluence/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/confluence/module.json b/modules/confluence/module.json index 18ff0ea6..18fb5e5e 100644 --- a/modules/confluence/module.json +++ b/modules/confluence/module.json @@ -5,6 +5,20 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the confluence tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the confluence tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", @@ -17,8 +31,18 @@ "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] } ], "capabilities": [ @@ -40,6 +64,11 @@ "MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token", "MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/de-spiegel/files/server.env b/modules/de-spiegel/files/server.env new file mode 100644 index 00000000..4ed1b5ad --- /dev/null +++ b/modules/de-spiegel/files/server.env @@ -0,0 +1,2 @@ +SMTP_AUTH_USER=${secret:smtp-user} +SMTP_AUTH_PASS=${secret:smtp-pass} diff --git a/modules/de-spiegel/module.json b/modules/de-spiegel/module.json index 6ace95ec..436349f5 100644 --- a/modules/de-spiegel/module.json +++ b/modules/de-spiegel/module.json @@ -42,7 +42,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n" + "template": "files/server.env" }, { "id": "net", @@ -66,5 +66,14 @@ "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" } } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/desk-channel/cmd/desk-channel/templates_test.go b/modules/desk-channel/cmd/desk-channel/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/desk-channel/cmd/desk-channel/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/desk-channel/files/settings.json b/modules/desk-channel/files/settings.json new file mode 100644 index 00000000..1ef16fd8 --- /dev/null +++ b/modules/desk-channel/files/settings.json @@ -0,0 +1,3 @@ +{ + "desktop-machines": [] +} diff --git a/modules/desk-channel/module.json b/modules/desk-channel/module.json index 309efbb4..3ee2dcc2 100644 --- a/modules/desk-channel/module.json +++ b/modules/desk-channel/module.json @@ -45,6 +45,14 @@ "tools": [ "desk_channel_status" ], + "config": { + "desktop-machines": { + "type": "list", + "description": "The machines, by node name, whose desktop shows the operator's questions and notifications, in the order they are tried; empty shows them on none.", + "trusted": true, + "default": [] + } + }, "resources": [ { "id": "account", @@ -66,8 +74,9 @@ "path": "${dir:state}/settings.json", "mode": "0600", "owner": "desk-channel", + "takes-config": ["desktop-machines"], "merge": "json", - "content": "{\n \"desktop-machines\": []\n}\n" + "template": "files/settings.json" } ], "build": { @@ -85,6 +94,11 @@ "env": { "MESH_DESK_SETTINGS": "${dir:state}/settings.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/disk-load/README.md b/modules/disk-load/README.md index 4ba20e5e..4d4f764e 100644 --- a/modules/disk-load/README.md +++ b/modules/disk-load/README.md @@ -3,7 +3,9 @@ How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue -312). It changes nothing and declares no resources, so it can be assigned to every machine. +312). It also reads a machine's CPU use by systemd unit and by container, from each cgroup's cpu.stat, +so an overload outside every container can be named (novox/hq issue 741). It changes nothing and +declares no resources, so it can be assigned to every machine. ## Why it exists, and why here @@ -22,7 +24,7 @@ machine's resources a seat, these tools become its verbs. ## Tools -All four only read. +All five only read. | tool | what | replaces | |---|---|---| @@ -30,6 +32,7 @@ All four only read. | `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` | | `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` | | `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` | +| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` | ### How each number is read diff --git a/modules/disk-load/cmd/disk-load/cgroups.go b/modules/disk-load/cmd/disk-load/cgroups.go index 79af261b..84f46e89 100644 --- a/modules/disk-load/cmd/disk-load/cgroups.go +++ b/modules/disk-load/cmd/disk-load/cgroups.go @@ -68,9 +68,10 @@ func (m *Machine) bottomDevices() map[string]bool { return out } -// unitIO is every unit's storage I/O, by its cgroup's path: the innermost .service or .scope, because -// io.stat counts a cgroup's descendants too and an outer unit would count them twice. -func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO { +// unitPaths is every systemd unit's cgroup path under /sys/fs/cgroup: the innermost .service or +// .scope, because a cgroup's own stat file counts its descendants too and an outer unit would count +// them twice. Shared by every reader that walks the cgroup tree (unitIO here, unitCPU in cpu.go). +func (m *Machine) unitPaths() []string { base := m.path("/sys/fs/cgroup") var units []string filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { @@ -89,11 +90,19 @@ func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO { outer[dir] = true } } - out := map[string]cgroupIO{} + out := make([]string, 0, len(units)) for _, u := range units { - if outer[u] { - continue + if !outer[u] { + out = append(out, u) } + } + return out +} + +// unitIO is every unit's storage I/O, by its cgroup's path. +func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO { + out := map[string]cgroupIO{} + for _, u := range m.unitPaths() { text := m.read("/sys/fs/cgroup/" + u + "/io.stat") if text == "" { continue diff --git a/modules/disk-load/cmd/disk-load/cpu.go b/modules/disk-load/cmd/disk-load/cpu.go new file mode 100644 index 00000000..efad5cb6 --- /dev/null +++ b/modules/disk-load/cmd/disk-load/cpu.go @@ -0,0 +1,176 @@ +package main + +import ( + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// cgroupCPU is a cgroup's processor time, from its cpu.stat. +type cgroupCPU struct { + usageUsec uint64 +} + +// parseCPUStat reads a cgroup's cpu.stat ("usage_usec 123\nuser_usec …\nsystem_usec …"), readable by +// every account, unlike /proc//stat for another account's process. +func parseCPUStat(s string) cgroupCPU { + var c cgroupCPU + for _, line := range strings.Split(s, "\n") { + k, v, ok := strings.Cut(line, " ") + if !ok { + continue + } + if k == "usage_usec" { + c.usageUsec, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64) + } + } + return c +} + +// unitCPU is every unit's processor time, by its cgroup's path. A cgroup whose cpu.stat cannot be +// read is left out, never an error for the whole answer. +func (m *Machine) unitCPU() map[string]cgroupCPU { + out := map[string]cgroupCPU{} + for _, u := range m.unitPaths() { + text := m.read("/sys/fs/cgroup/" + u + "/cpu.stat") + if text == "" { + continue + } + out[u] = parseCPUStat(text) + } + return out +} + +// MachineCPU is the machine's CPU in use over the window, from /proc/stat's cpu line. CPUsBusy is +// rounded only for display; raw, unexported, is the same count unrounded, for topCPU's share and +// unaccounted math (review round 1 of novox/hq issue 741: a small busy time that rounds CPUsBusy to +// 0.0 must not zero out every unit's share along with it). +type MachineCPU struct { + CPUs int `json:"cpus"` + BusyPct float64 `json:"busy_pct"` // % of all CPUs together + CPUsBusy float64 `json:"cpus_busy"` // as a count of CPUs, e.g. 7.5 of 12, rounded for display + raw float64 +} + +// machineCPUOver is the machine's CPU in use between two readings of /proc/stat: everything but idle +// and iowait, counted against the window's total ticks over every CPU. +func machineCPUOver(a, b cpuTimes) MachineCPU { + total := float64(b.total() - a.total()) + if total <= 0 { + return MachineCPU{CPUs: b.cpus} + } + idle := sub(b.idle, a.idle) + sub(b.iowait, a.iowait) + busy := total - idle + if busy < 0 { + busy = 0 + } + raw := busy / total * float64(b.cpus) + return MachineCPU{CPUs: b.cpus, BusyPct: round1(busy / total * 100), CPUsBusy: round1(raw), raw: raw} +} + +// CPUUsage is a unit's or a container's share of CPU between two readings of its cgroup's cpu.stat. +// Cgroup is its cgroup's path, relative to /sys/fs/cgroup, said beside Unit always (review round 1 of +// novox/hq issue 741): two cgroups can share a leaf name (an outer "init.scope" and a user session's +// "user.slice/user@1000.service/init.scope"), and the path is how they are told apart. +type CPUUsage struct { + Unit string `json:"unit,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + Container string `json:"container,omitempty"` + CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window + SharePct float64 `json:"share_pct"` // this unit's share of the machine's busy time + raw float64 +} + +// topCPU ranks the units, or the containers, with the most CPU time between two readings of +// cpu.stat, busiest first. unaccountedPct is the machine's busy time no unit accounts for, as a +// share of it (never below 0): the sum is always taken over every unit, whichever by ranks by, so a +// limit on the list never changes it. +func (m *Machine) topCPU(a, b map[string]cgroupCPU, window time.Duration, by string, limit int, cpusBusy float64) ([]CPUUsage, float64) { + windowUsec := window.Seconds() * 1e6 + type entry struct { + path, unit, container string + pct float64 + } + var entries []entry + var names map[string]string + var sumPct float64 + for path, end := range b { + start, ok := a[path] + if !ok { + continue + } + d := sub(end.usageUsec, start.usageUsec) + if d == 0 { + continue + } + pct := d / windowUsec * 100 + sumPct += pct + unit := filepath.Base(path) + var container string + if id := containerID.FindStringSubmatch("/" + path); id != nil { + if names == nil { + names = m.containerNames() + } + container = id[1][:12] + if n, ok := names[id[1]]; ok { + container = n + } + } + entries = append(entries, entry{path: path, unit: unit, container: container, pct: pct}) + } + + // Units are keyed by their cgroup's own path, never by its leaf name (filepath.Base): two cgroups can + // share a leaf name (an outer "init.scope" and a user session's nested one), and keying by the name + // alone would merge them into one row (review round 1 of novox/hq issue 741). Containers stay keyed + // by their name, as before. + groups := map[string]*CPUUsage{} + for _, e := range entries { + key := e.path + if by == "container" { + if e.container == "" { + continue + } + key = e.container + } + g, ok := groups[key] + if !ok { + g = &CPUUsage{Unit: e.unit, Cgroup: e.path, Container: e.container} + if by == "container" { + g.Unit, g.Cgroup = "", "" + } + groups[key] = g + } + g.raw += e.pct + } + out := make([]CPUUsage, 0, len(groups)) + for _, g := range groups { + out = append(out, *g) + } + sort.Slice(out, func(i, j int) bool { + if out[i].raw != out[j].raw { + return out[i].raw > out[j].raw + } + return out[i].Unit+out[i].Cgroup+out[i].Container < out[j].Unit+out[j].Cgroup+out[j].Container + }) + if len(out) > limit { + out = out[:limit] + } + for i := range out { + out[i].CPUPct = round1(out[i].raw) + if cpusBusy > 0 { + out[i].SharePct = round1(out[i].raw / (cpusBusy * 100) * 100) + } + } + + unaccounted := cpusBusy*100 - sumPct + if unaccounted < 0 { + unaccounted = 0 + } + unaccountedPct := 0.0 + if cpusBusy > 0 { + unaccountedPct = round1(unaccounted / (cpusBusy * 100) * 100) + } + return out, unaccountedPct +} diff --git a/modules/disk-load/cmd/disk-load/cpu_test.go b/modules/disk-load/cmd/disk-load/cpu_test.go new file mode 100644 index 00000000..5acc362a --- /dev/null +++ b/modules/disk-load/cmd/disk-load/cpu_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// aCPUHog is a machine whose CPU is 75 % busy (1.5 of 2 CPUs) over a 2 s window: a unit, another unit, +// and a container, each with a known usage_usec delta, leaving part of the machine's busy time +// unaccounted — the shape of issue 739, where 750 % of 12 CPUs went to processes outside containers. +func aCPUHog(t *testing.T) *fake { + f := newFake(t) + f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\ncpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\nintr 1\n") + f.then("/proc/stat", "cpu 100 0 50 50 0 0 0 0 0 0\ncpu0 50 0 25 25 0 0 0 0 0 0\ncpu1 50 0 25 25 0 0 0 0 0 0\nintr 2\n") + + zero := "usage_usec 0\nuser_usec 0\nsystem_usec 0\n" + cg := func(path string, usec int) { + f.file("/sys/fs/cgroup/"+path+"/cpu.stat", zero) + f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\nuser_usec "+itoa(usec)+"\nsystem_usec 0\n") + } + cg("system.slice/alpha.service", 1000000) // 50 % of one CPU over 2 s + cg("system.slice/beta.service", 400000) // 20 % + cg("system.slice/docker-"+store+".scope", 600000) // 30 %, named "cpu-hog" by the runtime + f.answers["docker ps --no-trunc --format {{.ID}}\t{{.Names}}"] = store + "\tcpu-hog\n" + return f +} + +func TestCPUTopRanksUnitsAndContainersByCPUOverTheWindow(t *testing.T) { + f := aCPUHog(t) + out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]any) + if m["by"] != "unit" || f.waited != 2*time.Second { + t.Errorf("the default: %+v, waited %s", m, f.waited) + } + cpu := m["cpu"].(MachineCPU) + if cpu.CPUs != 2 || cpu.BusyPct != 75 || cpu.CPUsBusy != 1.5 { + t.Errorf("machine cpu %+v", cpu) + } + top := m["top"].([]CPUUsage) + if len(top) != 3 { + t.Fatalf("three units moved: %+v", top) + } + if top[0].Unit != "alpha.service" || top[0].CPUPct != 50 || top[0].SharePct != 33.3 { + t.Errorf("busiest first: %+v", top[0]) + } + if top[1].Container != "cpu-hog" || top[1].CPUPct != 30 || top[1].SharePct != 20 { + t.Errorf("the container, named by the runtime: %+v", top[1]) + } + if top[2].Unit != "beta.service" || top[2].CPUPct != 20 || top[2].SharePct != 13.3 { + t.Errorf("the least busy: %+v", top[2]) + } + if m["unaccounted_pct"] != 33.3 { + t.Errorf("unaccounted: %+v (machine busy 150%% of one CPU, units sum to 100%%, 50%% of 150%% left over)", m["unaccounted_pct"]) + } +} + +func TestCPUTopByContainerNamesOnlyContainers(t *testing.T) { + out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "container", "seconds": 2.0}) + if err != nil { + t.Fatal(err) + } + top := out.(map[string]any)["top"].([]CPUUsage) + if len(top) != 1 || top[0].Container != "cpu-hog" || top[0].Unit != "" || top[0].CPUPct != 30 { + t.Errorf("by container: %+v", top) + } + if _, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "process"}); err == nil { + t.Error("by=process was accepted; cpu_top only ranks units and containers") + } +} + +func TestCPUTopLimitsHowManyAreNamed(t *testing.T) { + out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"limit": 1.0, "seconds": 2.0}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]any) + top := m["top"].([]CPUUsage) + if len(top) != 1 || top[0].Unit != "alpha.service" { + t.Errorf("limit 1: %+v", top) + } + // The limit narrows the list, never the unaccounted share, which is taken over every unit. + if m["unaccounted_pct"] != 33.3 { + t.Errorf("unaccounted with limit 1: %+v", m["unaccounted_pct"]) + } +} + +func TestCPUTopLeavesOutACgroupThatAppearsOrVanishesWithinTheWindow(t *testing.T) { + f := aCPUHog(t) + f.then("/sys/fs/cgroup/system.slice/short-lived.service/cpu.stat", "usage_usec 500000\n") + out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0}) + if err != nil { + t.Fatal(err) + } + top := out.(map[string]any)["top"].([]CPUUsage) + for _, u := range top { + if u.Unit == "short-lived.service" { + t.Errorf("a unit only in the second reading was counted: %+v", top) + } + } + if !strings.Contains(strings.Join(f.calls, ","), "docker ps") { + t.Errorf("the container runtime was never asked: %v", f.calls) + } +} + +// aTinyLoad is a machine with 12 CPUs, only 0.04 of them busy over a 2 s window (busy 1 of 300 total +// ticks): rounded to one decimal for display, CPUsBusy reads 0.0, but a unit using half of that busy +// time still has a non-zero share — review round 1's decision that share_pct and unaccounted_pct are +// computed from the unrounded busy time, never from the rounded CPUsBusy. +func aTinyLoad(t *testing.T) *fake { + f := newFake(t) + cpus := "cpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\ncpu2 0 0 0 0 0 0 0 0 0 0\ncpu3 0 0 0 0 0 0 0 0 0 0\n" + + "cpu4 0 0 0 0 0 0 0 0 0 0\ncpu5 0 0 0 0 0 0 0 0 0 0\ncpu6 0 0 0 0 0 0 0 0 0 0\ncpu7 0 0 0 0 0 0 0 0 0 0\n" + + "cpu8 0 0 0 0 0 0 0 0 0 0\ncpu9 0 0 0 0 0 0 0 0 0 0\ncpu10 0 0 0 0 0 0 0 0 0 0\ncpu11 0 0 0 0 0 0 0 0 0 0\n" + f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\n"+cpus) + f.then("/proc/stat", "cpu 1 0 0 299 0 0 0 0 0 0\n"+cpus) + + f.file("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 0\n") + f.then("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 60000\n") // 3 % of one CPU over 2 s + return f +} + +func TestCPUTopSharesAreNonZeroEvenWhenRoundedCPUsBusyIsZero(t *testing.T) { + out, err := tool(t, aTinyLoad(t).machine(), "cpu_top").Run(map[string]any{"seconds": 2.0}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]any) + cpu := m["cpu"].(MachineCPU) + if cpu.CPUs != 12 || cpu.CPUsBusy != 0 { + t.Fatalf("the machine 0.04 CPUs busy, rounded for display: %+v", cpu) + } + top := m["top"].([]CPUUsage) + if len(top) != 1 || top[0].Unit != "tiny.service" || top[0].CPUPct != 3 || top[0].SharePct != 75 { + t.Fatalf("a non-zero share from the unrounded busy time: %+v", top) + } + if m["unaccounted_pct"] != 25.0 { + t.Fatalf("unaccounted from the unrounded busy time: %+v (want 25, so shares plus unaccounted add to 100)", + m["unaccounted_pct"]) + } +} + +// TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows is review round 1's other decision: units are keyed +// by their cgroup path, never by filepath.Base, so two cgroups that both end in "init.scope" — one at the +// root, one under a user's session — are two rows, not one merged by their shared leaf name. +func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) { + f := aCPUHog(t) + cg := func(path string, usec int) { + f.file("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec 0\n") + f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\n") + } + cg("init.scope", 1000000) // the root init, 50 % of one CPU over 2 s + cg("user.slice/user@1000.service/init.scope", 400000) // a user session's init, 20 % + + out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0, "limit": 10.0}) + if err != nil { + t.Fatal(err) + } + top := out.(map[string]any)["top"].([]CPUUsage) + var inits []CPUUsage + for _, u := range top { + if u.Unit == "init.scope" { + inits = append(inits, u) + } + } + if len(inits) != 2 { + t.Fatalf("two cgroups named init.scope, merged into %d row(s): %+v", len(inits), top) + } + byCgroup := map[string]CPUUsage{} + for _, u := range inits { + byCgroup[u.Cgroup] = u + } + if u, ok := byCgroup["init.scope"]; !ok || u.CPUPct != 50 { + t.Errorf("the root init.scope: %+v", byCgroup) + } + if u, ok := byCgroup["user.slice/user@1000.service/init.scope"]; !ok || u.CPUPct != 20 { + t.Errorf("the user session's init.scope: %+v", byCgroup) + } +} diff --git a/modules/disk-load/cmd/disk-load/tools.go b/modules/disk-load/cmd/disk-load/tools.go index b54bf21e..ed29c9c3 100644 --- a/modules/disk-load/cmd/disk-load/tools.go +++ b/modules/disk-load/cmd/disk-load/tools.go @@ -85,6 +85,57 @@ func Tools(m *Machine) []stdio.Tool { return out, nil }, }, + { + Name: "cpu_top", + Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " + + "CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " + + "container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " + + "by name. Replaces top, systemd-cgtop. (r)", + Input: map[string]any{ + "seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"}, + "limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"}, + "by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"}, + }, + Run: func(args map[string]any) (any, error) { + n, err := count(args, "seconds", 3, 1, 10) + if err != nil { + return nil, err + } + w := time.Duration(n) * time.Second + limit, err := count(args, "limit", 15, 1, 50) + if err != nil { + return nil, err + } + by := text(args, "by") + switch by { + case "": + by = "unit" + case "unit", "container": + default: + return nil, fmt.Errorf("by is unit or container, not %q", by) + } + ac, err := parseCPU(m.read("/proc/stat")) + if err != nil { + return nil, err + } + a := m.unitCPU() + m.Wait(w) + bc, err := parseCPU(m.read("/proc/stat")) + if err != nil { + return nil, err + } + b := m.unitCPU() + machine := machineCPUOver(ac, bc) + top, unaccounted := m.topCPU(a, b, w, by, limit, machine.raw) + return map[string]any{ + "window_s": w.Seconds(), + "by": by, + "cpu": machine, + "top": top, + "unaccounted_pct": unaccounted, + }, nil + }, + }, { Name: "disk_filesystems", Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " + diff --git a/modules/disk-load/module.json b/modules/disk-load/module.json index 3b7ce911..e50253e6 100644 --- a/modules/disk-load/module.json +++ b/modules/disk-load/module.json @@ -5,7 +5,8 @@ "disk_load", "disk_top", "disk_filesystems", - "disk_devices" + "disk_devices", + "cpu_top" ], "build": { "artifacts": [ diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go b/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/dnsmasq/files/dnsmasq.conf b/modules/dnsmasq/files/dnsmasq.conf new file mode 100644 index 00000000..10f3926a --- /dev/null +++ b/modules/dnsmasq/files/dnsmasq.conf @@ -0,0 +1,87 @@ +# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than +# patched, because this module owns the file and a patch would leave whatever +# was there before to be discovered later. +# +# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the +# `mesh-dns-resolver` seat, which more than one machine may hold, each answering +# the same names from the same roster. Every node and every container lists every +# holder and no public resolver, so whichever answers first gives the one answer. +# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the +# mesh's, rendered into each holder; no other copy lives on any machine. + +# What the mesh computed: one wildcard per machine — its name and everything +# under it — and the mesh's own suffix as a local domain, so a name under it is +# answered here or not at all and is never asked upstream. Rewritten whenever a +# machine joins or leaves, which is why the service below restarts on it: a +# reload makes dnsmasq re-read hosts files, not its configuration, and a +# wildcard is configuration. +conf-file=/etc/mesh-resolver/nodes.conf + +# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to +# the private address of the machine its module runs on and the port its +# answerer is published on. Nothing in a zone is answered here; what names a zone +# holds is the module's. Rewritten whenever a zone is declared or moves, and the +# service restarts on it for the same reason as above. +conf-file=/etc/mesh-resolver/zones.conf + +# Where it answers: this machine's private address, so every node — and every +# container on every node, which copies its machine's resolvers — can ask; and +# loopback, for this machine's own use. Never a LAN address: a device that is +# not a member cannot reach what the mesh's names point at, and a LAN's resolver +# is its router's (ADR 0194). +# +# Named as an ADDRESS and not as the interface that carries it, on purpose. A +# container's query is addressed to this address but arrives on the runtime's +# bridge, and dnsmasq checks every query against what it was told to answer on: +# an `interface=` line admits queries by the interface they arrive on, a +# `listen-address=` line by the address they are sent to. With `interface=mesh0` +# alone, a query from a container was received and dropped without a word +# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on. +# +# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic +# re-reads the machine's addresses on every change of any link and closes each +# listener whose interface that reading did not find; a reading made while the +# container runtime deletes a bridge's virtual links fails ("index gone"), and +# then every listener is closed until a later change reads cleanly. On +# 2026-10-09 the private address was left unbound for twenty minutes, the daemon +# running and silent. bind-interfaces binds each address once, at start, and +# never closes it. It cannot bind an address that is not there yet, so the unit +# waits for it (the drop-in below); a new private address restarts the unit, as +# this file changing always did. +bind-interfaces +listen-address=${machine:address} +listen-address=127.0.0.1 + +# **It must never read resolv.conf to find out where to forward.** This +# machine's resolv.conf names this resolver — so a resolver that read it for +# upstreams would find itself, and every query it could not answer locally would +# loop until its receive queue filled. That is not theoretical: it filled with +# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop +# impossible: the upstreams are the two lines below, and nothing on the machine +# can redirect them. The mesh's own names never reach them: the local= line in +# the file above stops them here, answered or refused. +no-resolv +server=1.1.1.1 +server=8.8.8.8 + +# Both upstreams validate DNSSEC and say so with the Authenticated Data bit, +# and this passes that bit down rather than dropping it, which dnsmasq does +# unless told. It does not validate itself: an answer's trust is the upstream's +# and the path to it, which is what a forwarding resolver's trust always was. +# Without it a program that refuses to run behind a resolver that does not +# validate — a mail system's admin does exactly that check at start — cannot +# use this resolver (novox/hq 04-ISSUES/171). +proxy-dnssec + +# A name without a dot is never forwarded, and reverse lookups of private ranges +# are answered here rather than asking the world who 10.x is. +domain-needed +bogus-priv + +# **No hosts file, and no operator's files.** This machine's /etc/hosts is its +# own — the hosts module's block and the operator's lines (ADR 0199) — and the +# mesh's resolver answers every node, so a line written for one machine's own +# programs must not become an answer for all of them. Every per-node resolver +# went wrong exactly here: a hosts file read once at start, an operator's old +# line beside the mesh's, a drop-in read from a directory nobody owned. +no-hosts diff --git a/modules/dnsmasq/files/mesh.conf b/modules/dnsmasq/files/mesh.conf new file mode 100644 index 00000000..96d8d7d6 --- /dev/null +++ b/modules/dnsmasq/files/mesh.conf @@ -0,0 +1,14 @@ +# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push. +# +# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this +# machine's private address is there: it waits up to 80 seconds for it, and a start that found it +# missing is tried again every ten seconds rather than left failed. +[Unit] +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1' +Restart=on-failure +RestartSec=10s diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index d868d771..1865d9b7 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -70,7 +70,7 @@ "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n#\n# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the\n# `mesh-dns-resolver` seat, which more than one machine may hold, each answering\n# the same names from the same roster. Every node and every container lists every\n# holder and no public resolver, so whichever answers first gives the one answer.\n# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the\n# mesh's, rendered into each holder; no other copy lives on any machine.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to\n# the private address of the machine its module runs on and the port its\n# answerer is published on. Nothing in a zone is answered here; what names a zone\n# holds is the module's. Rewritten whenever a zone is declared or moves, and the\n# service restarts on it for the same reason as above.\nconf-file=/etc/mesh-resolver/zones.conf\n\n# Where it answers: this machine's private address, so every node — and every\n# container on every node, which copies its machine's resolvers — can ask; and\n# loopback, for this machine's own use. Never a LAN address: a device that is\n# not a member cannot reach what the mesh's names point at, and a LAN's resolver\n# is its router's (ADR 0194).\n#\n# Named as an ADDRESS and not as the interface that carries it, on purpose. A\n# container's query is addressed to this address but arrives on the runtime's\n# bridge, and dnsmasq checks every query against what it was told to answer on:\n# an `interface=` line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With `interface=mesh0`\n# alone, a query from a container was received and dropped without a word\n# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on.\n#\n# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic\n# re-reads the machine's addresses on every change of any link and closes each\n# listener whose interface that reading did not find; a reading made while the\n# container runtime deletes a bridge's virtual links fails (\"index gone\"), and\n# then every listener is closed until a later change reads cleanly. On\n# 2026-10-09 the private address was left unbound for twenty minutes, the daemon\n# running and silent. bind-interfaces binds each address once, at start, and\n# never closes it. It cannot bind an address that is not there yet, so the unit\n# waits for it (the drop-in below); a new private address restarts the unit, as\n# this file changing always did.\nbind-interfaces\nlisten-address=${machine:address}\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** This\n# machine's resolv.conf names this resolver — so a resolver that read it for\n# upstreams would find itself, and every query it could not answer locally would\n# loop until its receive queue filled. That is not theoretical: it filled with\n# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop\n# impossible: the upstreams are the two lines below, and nothing on the machine\n# can redirect them. The mesh's own names never reach them: the local= line in\n# the file above stops them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded, and reverse lookups of private ranges\n# are answered here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# **No hosts file, and no operator's files.** This machine's /etc/hosts is its\n# own — the hosts module's block and the operator's lines (ADR 0199) — and the\n# mesh's resolver answers every node, so a line written for one machine's own\n# programs must not become an answer for all of them. Every per-node resolver\n# went wrong exactly here: a hosts file read once at start, an operator's old\n# line beside the mesh's, a drop-in read from a directory nobody owned.\nno-hosts\n" + "template": "files/dnsmasq.conf" }, { "id": "drop-ins", @@ -83,7 +83,7 @@ "type": "file", "path": "/etc/systemd/system/dnsmasq.service.d/mesh.conf", "mode": "0644", - "content": "# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.\n#\n# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this\n# machine's private address is there: it waits up to 80 seconds for it, and a start that found it\n# missing is tried again every ten seconds rather than left failed.\n[Unit]\nAfter=network-online.target\nWants=network-online.target\nStartLimitIntervalSec=0\n\n[Service]\nExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo \"${machine:address} is not on this machine\" >&2; exit 1'\nRestart=on-failure\nRestartSec=10s\n" + "template": "files/mesh.conf" }, { "id": "service", @@ -148,6 +148,11 @@ "loads": [ "dnsmasq-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/docker/cmd/docker-tools/logrotation_test.go b/modules/docker/cmd/docker-tools/logrotation_test.go index e969730f..381df1e2 100644 --- a/modules/docker/cmd/docker-tools/logrotation_test.go +++ b/modules/docker/cmd/docker-tools/logrotation_test.go @@ -11,14 +11,15 @@ import ( "testing" ) -// The container runtime's log rotation is a preference (novox/hq issue 452). +// The container runtime's log rotation is two config values with defaults (novox/hq issue 452, ADR 0348 §4). type manifestForLogs struct { - Settings map[string]struct { - Kind string `json:"kind"` - Default any `json:"default"` - Why string `json:"why"` - } `json:"settings"` + Config map[string]struct { + Type string `json:"type"` + Description string `json:"description"` + Default any `json:"default"` + Trusted bool `json:"trusted"` + } `json:"config"` Resources []map[string]any `json:"resources"` } @@ -44,16 +45,16 @@ func resourceByID(m manifestForLogs, id string) (map[string]any, int) { return nil, -1 } -// filled is a file's content as the controller fills it from the settings' defaults alone: each -// ${setting:} replaced by the default as text, the way the controller writes a value into a file. +// filled is a file's content as the controller fills it from the config values' defaults alone: each +// ${config:} replaced by the default as text, the way the controller writes a value into a file. func filled(t *testing.T, m manifestForLogs, content string) string { t.Helper() - ref := regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`) + ref := regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`) var missing []string out := ref.ReplaceAllStringFunc(content, func(s string) string { key := ref.FindStringSubmatch(s)[1] - d, ok := m.Settings[key] - if !ok || d.Kind != "preference" { + d, ok := m.Config[key] + if !ok || d.Default == nil || d.Description == "" || !d.Trusted { missing = append(missing, key) return s } @@ -77,7 +78,7 @@ func TestTheDefaultLogRotationIsTheOneTheHandSetMachineHas(t *testing.T) { t.Fatal("no daemon resource") } var keys map[string]any - if err := json.Unmarshal([]byte(filled(t, m, daemon["content"].(string))), &keys); err != nil { + if err := json.Unmarshal([]byte(filled(t, m, templateText(t, daemon))), &keys); err != nil { t.Fatalf("daemon.json's keys with the defaults are not JSON: %v", err) } if keys["log-driver"] != "json-file" { @@ -125,7 +126,7 @@ func TestTheLogRotationIsCheckedBeforeDaemonJSONIsWritten(t *testing.T) { t.Errorf("log-rotation says trusted %v, want true", conf["trusted"]) } // The step reads the very values daemon.json takes: the same keys, filled the same way. - values, err := parseLogRotation(filled(t, m, conf["content"].(string))) + values, err := parseLogRotation(filled(t, m, templateText(t, conf))) if err != nil { t.Fatalf("the defaults are refused: %v", err) } @@ -133,8 +134,8 @@ func TestTheLogRotationIsCheckedBeforeDaemonJSONIsWritten(t *testing.T) { t.Errorf("defaults read as %+v", values) } daemon, _ := resourceByID(m, "daemon") - for _, key := range []string{"${setting:log-max-size}", "${setting:log-max-file}"} { - if !strings.Contains(daemon["content"].(string), key) || !strings.Contains(conf["content"].(string), key) { + for _, key := range []string{"${config:log-max-size}", "${config:log-max-file}"} { + if !strings.Contains(templateText(t, daemon), key) || !strings.Contains(templateText(t, conf), key) { t.Errorf("%s is not in both daemon and log-rotation", key) } } diff --git a/modules/docker/cmd/docker-tools/templates_test.go b/modules/docker/cmd/docker-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/docker/cmd/docker-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/docker/files/daemon.json b/modules/docker/files/daemon.json new file mode 100644 index 00000000..83750f4b --- /dev/null +++ b/modules/docker/files/daemon.json @@ -0,0 +1 @@ +{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} diff --git a/modules/docker/files/docker-prune.service b/modules/docker/files/docker-prune.service new file mode 100644 index 00000000..b9b92c1d --- /dev/null +++ b/modules/docker/files/docker-prune.service @@ -0,0 +1,15 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Prune dangling images and unused build cache (the mesh's docker module) +# Never volumes, never a container, never an image a container uses: dangling +# images and build cache nothing refers to, unused for a week. What a person +# prunes beyond that is docker_prune's, by hand. +After=docker.service +ConditionPathExists=/run/docker.sock + +[Service] +Type=oneshot +Nice=19 +IOSchedulingClass=idle +ExecStart=/usr/bin/docker image prune --force --filter until=168h +ExecStart=/usr/bin/docker builder prune --force --filter until=168h diff --git a/modules/docker/files/docker-prune.timer b/modules/docker/files/docker-prune.timer new file mode 100644 index 00000000..82befd15 --- /dev/null +++ b/modules/docker/files/docker-prune.timer @@ -0,0 +1,11 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Weekly prune of dangling images and unused build cache (the mesh's docker module) + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/modules/docker/files/log-rotation.conf b/modules/docker/files/log-rotation.conf new file mode 100644 index 00000000..39190368 --- /dev/null +++ b/modules/docker/files/log-rotation.conf @@ -0,0 +1,5 @@ +# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and +# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452). +# Replaced on every push; change the settings, never this file. +size=${config:log-max-size} +files=${config:log-max-file} diff --git a/modules/docker/module.json b/modules/docker/module.json index 3d267b9d..80f0bed3 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -44,18 +44,23 @@ "docker exec cat /etc/resolv.conf" ] }, - "settings": { - "log-max-size": { - "kind": "preference", - "default": "100m", - "why": "each container's log is cut at this size and the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container: what the one machine with rotation set by hand has (novox/hq issue 452). A whole number from 1 with k, m or g after it, refused before daemon.json is written. The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again" + "config": { + "log-max-size": { + "type": "string", + "description": "The size each container's log is cut at, a whole number from 1 with k, m or g after it; the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container (novox/hq issue 452). The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again.", + "pattern": "[1-9][0-9]*[kmg]", + "trusted": true, + "default": "100m" + }, + "log-max-file": { + "type": "number", + "description": "How many of a container's log files of log-max-size the runtime keeps, a count from 1 to 99, the oldest dropped first; taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452).", + "minimum": 1, + "maximum": 99, + "trusted": true, + "default": 10 + } }, - "log-max-file": { - "kind": "preference", - "default": 10, - "why": "how many of a container's log files of log-max-size the runtime keeps, the oldest dropped first; a count from 1 to 99, refused before daemon.json is written, taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452)" - } - }, "resources": [ { "id": "package", @@ -86,7 +91,7 @@ "path": "${dir:state}/log-rotation.conf", "mode": "0600", "trusted": true, - "content": "# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and\n# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452).\n# Replaced on every push; change the settings, never this file.\nsize=${setting:log-max-size}\nfiles=${setting:log-max-file}\n" + "template": "files/log-rotation.conf" }, { "id": "log-rotation-check", @@ -112,7 +117,7 @@ "mode": "0644", "into": "json", "trusted": true, - "content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"], \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"${setting:log-max-size}\", \"max-file\": \"${setting:log-max-file}\"}}\n" + "template": "files/daemon.json" }, { "id": "runtime", @@ -129,14 +134,14 @@ "type": "file", "path": "/etc/systemd/system/docker-prune.service", "mode": "0644", - "content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Prune dangling images and unused build cache (the mesh's docker module)\n# Never volumes, never a container, never an image a container uses: dangling\n# images and build cache nothing refers to, unused for a week. What a person\n# prunes beyond that is docker_prune's, by hand.\nAfter=docker.service\nConditionPathExists=/run/docker.sock\n\n[Service]\nType=oneshot\nNice=19\nIOSchedulingClass=idle\nExecStart=/usr/bin/docker image prune --force --filter until=168h\nExecStart=/usr/bin/docker builder prune --force --filter until=168h\n" + "template": "files/docker-prune.service" }, { "id": "prune-timer", "type": "file", "path": "/etc/systemd/system/docker-prune.timer", "mode": "0644", - "content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Weekly prune of dangling images and unused build cache (the mesh's docker module)\n\n[Timer]\nOnCalendar=weekly\nRandomizedDelaySec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n" + "template": "files/docker-prune.timer" }, { "id": "prune", @@ -162,6 +167,11 @@ "loads": [ "docker-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go index 55f2f09e..2959a8b6 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go @@ -12,29 +12,39 @@ import ( // every desktop module that carries it. type manifest struct { - Module string `json:"module"` - Version string `json:"version"` - Capabilities []string `json:"capabilities"` - Requires []string `json:"requires"` - Claims []claim `json:"claims"` - Seats []any `json:"seats"` - Tools []string `json:"tools"` - Settings map[string]setting `json:"settings"` - Emits []string `json:"emits"` - Contributions []contribution `json:"contributions"` - Environment *environment `json:"environment"` - Shell []shellCode `json:"shell"` - Resources []map[string]any `json:"resources"` + Module string `json:"module"` + Version string `json:"version"` + Moved bool `json:"moved"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Config map[string]config `json:"config"` + Emits []string `json:"emits"` + Items []item `json:"items"` + Contributions []any `json:"contributions"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } -// setting is one key's default, as the definition gives it (novox/hq ADR 0262). -type setting struct { - Kind string `json:"kind"` - Default any `json:"default"` - Why string `json:"why"` +// config is one config value as the definition declares it (novox/hq ADR 0348 §4). +type config struct { + Type string `json:"type"` + Description string `json:"description"` + Enum []any `json:"enum"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Pattern string `json:"pattern"` + Trusted bool `json:"trusted"` + Predefined string `json:"predefined"` + Default any `json:"default"` + Required bool `json:"required"` + Runtime bool `json:"runtime"` } type claim struct { @@ -93,26 +103,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { @@ -184,31 +234,49 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } -type contribution struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` -} +// displaySession is the collection the module's lines for the window manager are an item of. +const displaySession = "contribution:node-display-session:config" -// i3Lines is what the module contributes to the window manager. +// i3Lines is what the module contributes to the window manager: the text of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" && c.Kind == "config" { - out += c.Content + for _, it := range m.Items { + if it.Collection == displaySession { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + out += string(text) } } return out } -// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no +// old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) + if len(m.Contributions) != 0 { + t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } - if m.i3Lines() != string(want) { - t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + var templates []string + for _, it := range m.Items { + if it.Collection == displaySession { + templates = append(templates, it.Template) + } + } + if len(templates) != 1 || templates[0] != source { + t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) + } + if m.i3Lines() == "" { + t.Fatalf("the template %s holds no line", source) } } + +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go index 2712ea02..3f56b5fd 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -29,7 +29,7 @@ func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) { func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) { m := readManifest(t) - m.sameAsSource(t, "configuration", "files/dunstrc") + m.fromTemplate(t, "configuration", "files/dunstrc") if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" { t.Fatalf("path: %v", p) } @@ -45,9 +45,9 @@ func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) { func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) { m := readManifest(t) - c := m.resource(t, "configuration")["content"].(string) - for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${setting:font-size}", - "width = ${setting:width}", "dmenu = rofi -dmenu -no-custom -p dunst"} { + c := m.text(t, "configuration") + for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${config:font-size}", + "width = ${config:width}", "dmenu = rofi -dmenu -no-custom -p dunst"} { if !strings.Contains(c, " "+want+"\n") { t.Errorf("lacks %q", want) } @@ -67,7 +67,7 @@ func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) { // actions closes. Every other click is as it was. func TestATapChoosesTheNotificationsAction(t *testing.T) { m := readManifest(t) - c := m.resource(t, "configuration")["content"].(string) + c := m.text(t, "configuration") for _, want := range []string{"mouse_left_click = do_action, close_current", "mouse_middle_click = do_action, close_current", "mouse_right_click = close_all"} { if !strings.Contains(c, " "+want+"\n") { @@ -95,31 +95,37 @@ func TestTheMenuKeyIsABindingI3Places(t *testing.T) { } } -// The font size and the width are preferences with today's values as their defaults (novox/hq ADR -// 0262): a machine with a denser screen sets its own, and every other machine's file is unchanged. -func TestTheFontSizeAndWidthAreSettingsWithTodaysDefaults(t *testing.T) { +// The font size and the width are config values with today's values as their defaults (novox/hq ADR +// 0348 §4): numbers with their unit said and their bounds, the font size falling back to the mesh's +// predefined font-size (ADR 0348 §2), so a machine with a denser screen sets its own and every other +// machine's file is unchanged. Neither is trusted: the file says "trusted": false. +func TestTheFontSizeAndWidthAreConfigValuesWithTodaysDefaults(t *testing.T) { m := readManifest(t) want := map[string]float64{"font-size": 10, "width": 250} - if len(m.Settings) != len(want) { - t.Fatalf("settings: %+v", m.Settings) + if len(m.Config) != len(want) { + t.Fatalf("config: %+v", m.Config) } for key, value := range want { - s, ok := m.Settings[key] - if !ok || s.Kind != "preference" || s.Default != value || strings.TrimSpace(s.Why) == "" { - t.Errorf("%s: %+v, want a preference defaulting to %v with why", key, s, value) + s, ok := m.Config[key] + if !ok || s.Type != "number" || s.Default != value || s.Required || s.Trusted || + strings.TrimSpace(s.Description) == "" || s.Minimum == nil || s.Maximum == nil { + t.Errorf("%s: %+v, want an untrusted number defaulting to %v, with a description and bounds", key, s, value) } } - // Filled with the defaults, the file is the one every machine had before it took settings. - c := m.resource(t, "configuration")["content"].(string) - c = strings.ReplaceAll(c, "${setting:font-size}", "10") - c = strings.ReplaceAll(c, "${setting:width}", "250") + if p := m.Config["font-size"].Predefined; p != "font-size" { + t.Errorf("the font size falls back to %q, want the predefined font-size", p) + } + // Filled with the defaults, the file is the one every machine had before it took config values. + c := m.text(t, "configuration") + c = strings.ReplaceAll(c, "${config:font-size}", "10") + c = strings.ReplaceAll(c, "${config:width}", "250") for _, want := range []string{"font = Inter 10", "width = 250"} { if !strings.Contains(c, " "+want+"\n") { t.Errorf("filled with the defaults, lacks %q", want) } } - if strings.Contains(c, "${setting:") { - t.Errorf("asks for a setting it gives no default: %s", c) + if strings.Contains(c, "${config:") || strings.Contains(c, "${setting:") { + t.Errorf("asks for a config value it gives no default: %s", c) } } @@ -145,3 +151,21 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} + +// dunst moves first (design 57 P6, issue 672): marked moved, its dunstrc is filled on the node by the provisioner's +// client from its template, and reloaded as the file declares. +func TestItIsHandedToTheProvisioner(t *testing.T) { + m := readManifest(t) + if !m.Moved { + t.Fatal("dunst is not marked moved") + } + m.fromTemplate(t, "configuration", "files/dunstrc") + if on := m.resource(t, "notifier")["reload-on"]; !reflect.DeepEqual(on, []any{"configuration"}) { + t.Fatalf("the notifier is not reloaded on its dunstrc: %v", on) + } +} diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index 40d3eb71..afba676f 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -13,7 +13,7 @@ follow = none # Geometry - width = ${setting:width} + width = ${config:width} height = (0, 300) origin = top-right offset = (10, 50) @@ -39,7 +39,7 @@ corner_radius = 0 # Text: the interface face (research 026/04) - font = Inter ${setting:font-size} + font = Inter ${config:font-size} line_height = 0 markup = full format = "%s\n%b" diff --git a/modules/dunst/module.json b/modules/dunst/module.json index f94d5d96..fa4923db 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -1,6 +1,7 @@ { "module": "dunst", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -22,18 +23,23 @@ "dunst_count", "dunst_reload" ], - "settings": { - "font-size": { - "kind": "preference", - "default": 10, - "why": "10 points of Inter reads well on a screen of about 100 DPI; a denser screen needs a larger size" + "config": { + "font-size": { + "type": "number", + "description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 10 + }, + "width": { + "type": "number", + "description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.", + "minimum": 50, + "maximum": 4000, + "default": 250 + } }, - "width": { - "kind": "preference", - "default": 250, - "why": "250 pixels fits a title of about forty characters at 10 points; a larger font needs a wider notification" - } - }, "resources": [ { "id": "package", @@ -66,7 +72,7 @@ "path": "${machine:account-home}/.config/dunst/dunstrc", "owner": "${machine:account}", "mode": "0644", - "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -no-custom -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" + "template": "files/dunstrc" }, { "id": "notifier", @@ -91,6 +97,11 @@ "loads": [ "dunst-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -99,11 +110,11 @@ "started", "paused" ], - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-dunst.conf" } ] } diff --git a/modules/fail2ban/cmd/fail2ban-tools/own_test.go b/modules/fail2ban/cmd/fail2ban-tools/own_test.go index 293257b0..8a8699f2 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/own_test.go +++ b/modules/fail2ban/cmd/fail2ban-tools/own_test.go @@ -267,7 +267,7 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) { for _, r := range m.Resources { byID[r["id"].(string)] = r } - base, _ := byID["jail-local"]["content"].(string) + base := templateText(t, byID["jail-local"]) var ignoreip []string for _, l := range strings.Split(base, "\n") { if strings.HasPrefix(l, "ignoreip") { @@ -278,17 +278,20 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) { t.Errorf("the base ignore list: %q", ignoreip) } for id, r := range byID { - if content, _ := r["content"].(string); id != "jail-local" && strings.Contains(content, "ignoreip") { + if r["type"] != "file" || id == "jail-local" { + continue + } + if strings.Contains(templateText(t, r), "ignoreip") { t.Errorf("%s sets an ignore list of its own, which leaves the operator's own addresses out", id) } } setting := byID["own-addresses"] if setting["path"] != SettingFile || setting["merge"] != "json" || setting["trusted"] != true || - !reflect.DeepEqual(setting["takes-settings"], []any{SettingKey}) { + !reflect.DeepEqual(setting["takes-config"], []any{SettingKey}) { t.Errorf("the setting's file: %v", setting) } var content map[string]any - if err := json.Unmarshal([]byte(setting["content"].(string)), &content); err != nil || + if err := json.Unmarshal([]byte(templateText(t, setting)), &content); err != nil || !reflect.DeepEqual(content, map[string]any{SettingKey: []any{}}) { t.Errorf("the setting starts from %v (%v)", content, err) } diff --git a/modules/fail2ban/cmd/fail2ban-tools/templates_test.go b/modules/fail2ban/cmd/fail2ban-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/fail2ban/files/fail2ban b/modules/fail2ban/files/fail2ban new file mode 100644 index 00000000..b5395db6 --- /dev/null +++ b/modules/fail2ban/files/fail2ban @@ -0,0 +1,7 @@ +/var/log/fail2ban.log { + missingok + notifempty + postrotate + /usr/bin/fail2ban-client flushlogs >/dev/null || true + endscript +} diff --git a/modules/fail2ban/files/iptables-allports-dualchain.conf b/modules/fail2ban/files/iptables-allports-dualchain.conf new file mode 100644 index 00000000..2c9fc73d --- /dev/null +++ b/modules/fail2ban/files/iptables-allports-dualchain.conf @@ -0,0 +1,40 @@ +# Fail2Ban action: ban in both INPUT and DOCKER-USER chains +# Used by recidive to block repeat offenders from both host and Docker services + +[INCLUDES] + +before = iptables.conf + +[Definition] + +type = allports + +actionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; } + { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; } + { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; } + +actionstop = -D INPUT -p -j f2b- 2>/dev/null || true + -D DOCKER-USER -p -j f2b- 2>/dev/null || true + -F f2b- + -X f2b- + +actioncheck = -n -L f2b- >/dev/null + +actionban = -I f2b- 1 -s -j + +actionunban = -D f2b- -s -j + +[Init] + +chain = INPUT +name = default +protocol = tcp +blocktype = REJECT --reject-with icmp-port-unreachable +returntype = RETURN +lockingopt = -w +iptables = iptables + +[Init?family=inet6] + +blocktype = REJECT --reject-with icmp6-port-unreachable +iptables = ip6tables diff --git a/modules/fail2ban/files/jail.local b/modules/fail2ban/files/jail.local new file mode 100644 index 00000000..1fa0ffe7 --- /dev/null +++ b/modules/fail2ban/files/jail.local @@ -0,0 +1,48 @@ +[INCLUDES] + +before = paths-arch.conf + +[DEFAULT] + +# Never act on the machine itself or on a tunnel peer: the mesh's private range is +# ${machine:mesh-range}, named here rather than written as a value the module cannot +# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes. +# **A ban list never holds a neighbour.** The mesh's own range is named rather than written +# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own +# network, not the internet. On a machine behind a router that reflects local traffic, every +# client in the house arrives as the gateway's address — so one mistyped local request banned +# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186). +# +# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names +# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned +# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found +# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban +# reads it again within a second of a change, so a new value needs no restart. Empty until the operator +# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so. +ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses + +# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace +# themselves at one try every ten minutes, under any ten-minute window; a day's window counts +# them, and a day's ban costs a person who mistyped three times once, from one address, while +# the mesh's own range is never banned at all. +bantime = 1d +findtime = 1d +maxretry = 3 + +# Ban through iptables, not through a firewall front-end the machine may not have. ufw is +# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out +# only at ban time: the service reports healthy, the jail counts the attempt, the ban command +# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a +# machine the mesh reported as protected. +# +# The action below is this module's own, already used by the recidive jail on every machine +# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is +# covered too. +banaction = iptables-allports-dualchain +banaction_allports = iptables-allports-dualchain + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s diff --git a/modules/fail2ban/files/mesh-own-addresses.json b/modules/fail2ban/files/mesh-own-addresses.json new file mode 100644 index 00000000..0eb96046 --- /dev/null +++ b/modules/fail2ban/files/mesh-own-addresses.json @@ -0,0 +1,3 @@ +{ + "own-addresses": [] +} diff --git a/modules/fail2ban/files/own-addresses b/modules/fail2ban/files/own-addresses new file mode 100644 index 00000000..e69de29b diff --git a/modules/fail2ban/files/recidive.conf b/modules/fail2ban/files/recidive.conf new file mode 100644 index 00000000..1f8786fc --- /dev/null +++ b/modules/fail2ban/files/recidive.conf @@ -0,0 +1,9 @@ +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services) +banaction = iptables-allports-dualchain +# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179). +bantime = 4w +findtime = 2w +maxretry = 2 diff --git a/modules/fail2ban/files/sshd.conf b/modules/fail2ban/files/sshd.conf new file mode 100644 index 00000000..c8a30f29 --- /dev/null +++ b/modules/fail2ban/files/sshd.conf @@ -0,0 +1,8 @@ +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s +maxretry = 3 +findtime = 1d +bantime = 1d diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 89158bbe..d2aa2a65 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -24,6 +24,14 @@ "into": "/etc/fail2ban/jail.d/mesh.conf", "filter-into": "/etc/fail2ban/filter.d" }, + "config": { + "own-addresses": { + "type": "list", + "description": "The addresses and networks outside the mesh that are the operator's own, each an IP address or a CIDR network: every jail ignores them beside the private ranges, the ban verb refuses them, and a failure a jail counted from one is said as a warning; empty is none.", + "trusted": true, + "default": [] + } + }, "resources": [ { "id": "package", @@ -42,7 +50,7 @@ "path": "/var/lib/mesh-fail2ban/own-addresses", "mode": "0644", "create-once": true, - "content": "" + "template": "files/own-addresses" }, { "id": "jail-d", @@ -73,14 +81,14 @@ "type": "file", "path": "/etc/fail2ban/jail.local", "mode": "0644", - "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\n#\n# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names\n# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned\n# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found\n# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban\n# reads it again within a second of a change, so a new value needs no restart. Empty until the operator\n# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" + "template": "files/jail.local" }, { "id": "jail-sshd", "type": "file", "path": "/etc/fail2ban/jail.d/sshd.conf", "mode": "0644", - "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n" + "template": "files/sshd.conf" }, { "id": "own-addresses", @@ -88,11 +96,11 @@ "path": "/etc/fail2ban/mesh-own-addresses.json", "mode": "0644", "merge": "json", - "takes-settings": [ + "takes-config": [ "own-addresses" ], "trusted": true, - "content": "{\n \"own-addresses\": []\n}\n" + "template": "files/mesh-own-addresses.json" }, { "id": "watch", @@ -118,28 +126,28 @@ "path": "/var/log/fail2ban.log", "mode": "0640", "create-once": true, - "content": "" + "template": "files/own-addresses" }, { "id": "jail-recidive", "type": "file", "path": "/etc/fail2ban/jail.d/recidive.conf", "mode": "0644", - "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n" + "template": "files/recidive.conf" }, { "id": "action-dualchain", "type": "file", "path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf", "mode": "0644", - "content": "# Fail2Ban action: ban in both INPUT and DOCKER-USER chains\n# Used by recidive to block repeat offenders from both host and Docker services\n\n[INCLUDES]\n\nbefore = iptables.conf\n\n[Definition]\n\ntype = allports\n\nactionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; }\n { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; }\n { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; }\n\nactionstop = -D INPUT -p -j f2b- 2>/dev/null || true\n -D DOCKER-USER -p -j f2b- 2>/dev/null || true\n -F f2b-\n -X f2b-\n\nactioncheck = -n -L f2b- >/dev/null\n\nactionban = -I f2b- 1 -s -j \n\nactionunban = -D f2b- -s -j \n\n[Init]\n\nchain = INPUT\nname = default\nprotocol = tcp\nblocktype = REJECT --reject-with icmp-port-unreachable\nreturntype = RETURN\nlockingopt = -w\niptables = iptables \n\n[Init?family=inet6]\n\nblocktype = REJECT --reject-with icmp6-port-unreachable\niptables = ip6tables \n" + "template": "files/iptables-allports-dualchain.conf" }, { "id": "logrotate", "type": "file", "path": "/etc/logrotate.d/fail2ban", "mode": "0644", - "content": "/var/log/fail2ban.log {\n missingok\n notifempty\n postrotate\n /usr/bin/fail2ban-client flushlogs >/dev/null || true\n endscript\n}\n" + "template": "files/fail2ban" }, { "id": "run", @@ -171,6 +179,11 @@ "loads": [ "fail2ban-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/feh/cmd/feh-tools/manifest_helpers_test.go b/modules/feh/cmd/feh-tools/manifest_helpers_test.go index 441415f9..28adb660 100644 --- a/modules/feh/cmd/feh-tools/manifest_helpers_test.go +++ b/modules/feh/cmd/feh-tools/manifest_helpers_test.go @@ -14,6 +14,8 @@ import ( type manifest struct { Module string `json:"module"` Version string `json:"version"` + Moved bool `json:"moved"` + Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` @@ -23,7 +25,7 @@ type manifest struct { Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. - Contributions []contribution `json:"contributions"` + Contributions []any `json:"contributions"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` @@ -46,6 +48,53 @@ type shellCode struct { Code string `json:"code"` } +// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} + +// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of +// the collections shell::, in the order they are declared. +func (m manifest) shell(t *testing.T) []shellCode { + t.Helper() + var out []shellCode + for _, it := range m.Items { + parts := strings.Split(it.Collection, ":") + if len(parts) != 3 || parts[0] != "shell" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) + } + return out +} + +// environmentOf is the variables the module sets, read from the templates of its items of the environment. +func (m manifest) environmentOf(t *testing.T) *environment { + t.Helper() + var env *environment + for _, it := range m.Items { + if it.Collection != "environment" || it.Attributes["kind"] != "variable" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + if env == nil { + env = &environment{Variables: map[string]string{}} + } + env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + return env +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -58,6 +107,7 @@ func readManifest(t *testing.T) manifest { if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } + m.Environment = m.environmentOf(t) return m } @@ -85,26 +135,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { @@ -176,31 +266,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } -type contribution struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` -} +// displaySession is the collection the module's lines for the window manager are an item of. +const displaySession = "contribution:node-display-session:config" -// i3Lines is what the module contributes to the window manager. +// i3Lines is what the module contributes to the window manager: the text of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" && c.Kind == "config" { - out += c.Content + for _, it := range m.Items { + if it.Collection == displaySession { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + out += string(text) } } return out } -// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no +// old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) + if len(m.Contributions) != 0 { + t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } - if m.i3Lines() != string(want) { - t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + var templates []string + for _, it := range m.Items { + if it.Collection == displaySession { + templates = append(templates, it.Template) + } + } + if len(templates) != 1 || templates[0] != source { + t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) + } + if m.i3Lines() == "" { + t.Fatalf("the template %s holds no line", source) } } diff --git a/modules/feh/cmd/feh-tools/manifest_test.go b/modules/feh/cmd/feh-tools/manifest_test.go index b02c1490..0dc0de86 100644 --- a/modules/feh/cmd/feh-tools/manifest_test.go +++ b/modules/feh/cmd/feh-tools/manifest_test.go @@ -48,16 +48,16 @@ func TestTheWallpaperIsTheModulesOwnArchive(t *testing.T) { func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) { m := readManifest(t) - m.sameAsSource(t, "fehbg", "files/fehbg") + m.fromTemplate(t, "fehbg", "files/fehbg") f := m.resource(t, "fehbg") if f["path"] != "${machine:account-home}/.fehbg" || f["mode"] != "0755" { t.Fatalf("%v", f) } - if c := f["content"].(string); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") { + if c := m.text(t, "fehbg"); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") { t.Fatalf("%s", c) } - if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || strings.Count(m.Shell[0].Code, `"$HOME/.fehbg"`) != 1 { - t.Fatalf("%+v", m.Shell) + if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "normal" || strings.Count(m.shell(t)[0].Code, `"$HOME/.fehbg"`) != 1 { + t.Fatalf("%+v", m.shell(t)) } } @@ -77,3 +77,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} diff --git a/modules/feh/files/xinitrc-normal b/modules/feh/files/xinitrc-normal new file mode 100644 index 00000000..8e91e82e --- /dev/null +++ b/modules/feh/files/xinitrc-normal @@ -0,0 +1,2 @@ +# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session. +"$HOME/.fehbg" diff --git a/modules/feh/module.json b/modules/feh/module.json index 70a20714..eb2c69bd 100644 --- a/modules/feh/module.json +++ b/modules/feh/module.json @@ -1,6 +1,7 @@ { "module": "feh", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -11,11 +12,16 @@ "feh_set", "feh_current" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.\n\"$HOME/.fehbg\"\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-feh.conf" } ], "resources": [ @@ -37,7 +43,7 @@ "path": "${machine:account-home}/.fehbg", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n" + "template": "files/fehbg" } ], "build": { @@ -57,14 +63,12 @@ "loads": [ "feh-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n" - } - ] + } } diff --git a/modules/fonts/cmd/fonts-tools/fonts_test.go b/modules/fonts/cmd/fonts-tools/fonts_test.go index 8fe9b19f..df4344c7 100644 --- a/modules/fonts/cmd/fonts-tools/fonts_test.go +++ b/modules/fonts/cmd/fonts-tools/fonts_test.go @@ -56,7 +56,7 @@ type fcDoc struct { func TestTheFontconfigFileMapsEachGenericToTheDecidedFaceStronglyAndFallsBackLast(t *testing.T) { m := readManifest(t) - content, _ := m.resource("defaults")["content"].(string) + content := templateText(t, m.resource("defaults")) if !strings.HasPrefix(content, " + + + + The mesh: the faces monospace, sans-serif, serif and emoji mean + + + Hack Nerd Fontmonospace + MesloLGS NFmonospace + Iosevka Nerd Fontmonospace + JetBrains Mono Nerd FontJetBrainsMono Nerd Font + + + monospaceJetBrainsMono Nerd Font + sans-serifInterNoto Sans + system-uiInter + serifNoto Serif + emojiNoto Color Emoji + + + + Symbols Nerd Font + Noto Color Emoji + + diff --git a/modules/fonts/module.json b/modules/fonts/module.json index e6684403..b2958da8 100644 --- a/modules/fonts/module.json +++ b/modules/fonts/module.json @@ -44,7 +44,7 @@ "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf", "owner": "${machine:account}", "mode": "0644", - "content": "\n\n\n\n The mesh: the faces monospace, sans-serif, serif and emoji mean\n\n \n Hack Nerd Fontmonospace\n MesloLGS NFmonospace\n Iosevka Nerd Fontmonospace\n JetBrains Mono Nerd FontJetBrainsMono Nerd Font\n\n \n monospaceJetBrainsMono Nerd Font\n sans-serifInterNoto Sans\n system-uiInter\n serifNoto Serif\n emojiNoto Color Emoji\n\n \n \n Symbols Nerd Font\n Noto Color Emoji\n \n\n" + "template": "files/50-mesh-fonts.conf" } ], "build": { @@ -59,6 +59,11 @@ "loads": [ "fonts-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/gitea/README.md b/modules/gitea/README.md index cb5edb23..d1a3397b 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -44,7 +44,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag | tool | | what | |---|---|---| -| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) or `patch` (a unified diff, as `git diff` writes it); answers the commit and its parent | +| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent | | `gitea_commits` | r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject | | `gitea_compare` | r | what head holds that base does not: its commits and the files they touch | | `gitea_pull_reviews` | r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments | @@ -52,13 +52,28 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag | `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path | **Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266): -never the repository's default branch, never a branch a protection rule names. A rule covers a branch as the +never the repository's default branch, never a branch a protection rule names. The tools' account may not read the +rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch +is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which +refuses the commit that would make a branch a rule guards. A rule covers a branch as the forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob (`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the commit. Nothing about merging changes: a merge still needs an independent review at the head and both required checks green, through `gitea_merge_pull_request`. +**A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with +`patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches` +(`/var/lib/mesh/forge-patches`, mode 0733 and owned by the machine's account, which the tools run as: anyone writes, +nobody lists; the bundle is given its path as `MESH_GITEA_PATCH_DIR`) and names it: `patch_file` is the file's NAME, +32 to 64 characters of `0-9a-f` that the caller chooses at random, never a path. The tool then reads that one file, +applies it exactly as it applies `patch` (same size limit, same answer) and removes it once it has found it, whether +the commit was made or not. A file refused before that (a bad name, a missing hash or spool directory, a name with no +file) stays in the spool. It refuses, sending nothing, a name of another shape, a file that is missing, empty, over +4 MiB, with more than one hard link or not a regular file (a symlink is never followed), and a file whose SHA-256 is not `patch_sha256`: the directory has no sticky bit +(the manifest allows none), so another writer could replace a file, and the hash is how the caller knows the tool read +the bytes it wrote. It reads nothing outside the directory. + **How the commit is made.** `files` become one call to the forge's multi-file contents endpoint: a path the ref holds is updated, one it does not is created, and an update or delete carries the blob read just before, so a file someone changed meanwhile is refused by the forge rather than overwritten. The blobs are read at @@ -66,7 +81,7 @@ the commit the branch (or, for a new branch, its base) was read at, never at its forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the one the forge says it made the commit on; when the branch moved between the read and the commit, the answer -says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the forge's admin account, so its sha is not the +says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a branch not at the commit the forge named is a failure, never a success. @@ -75,8 +90,10 @@ is read once and searched in memory; a binary file is passed over, a file over 4 `skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`. -It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), and one -tool call takes at most 50 seconds. +It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the +tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and +keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool +call takes at most 50 seconds. `go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on a new branch from its base; an existing branch committed on; the default branch, a protected branch and unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another @@ -85,6 +102,42 @@ changed under the commit refused; a commit over its size limits refused; protect forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read summarised. +## Each agent's read credential (novox/hq ADR 0365 §1, §3–§5, issue 737) + +An agent clones and fetches the repositories it may read as a forge account of its own, `agent-`. The +module offers the provision `gitea-read`, whose credential its software issues (ADR 0305) one per holder (issue +749): the Go bundle `cmd/agent-token` serves `gitea_agent_token`, which the vault alone asks (ADR 0349 clause 10; +any other caller is refused and nothing is minted). For each holder the vault names that is the claude-code module +on a machine, it + +- makes the account `agent-` with the administrator's login, when there is none: no administrator, a random + password sent once and kept nowhere, and **in no team** — it reads public repositories until the operator adds it + to a team that reads more (§3). An account found made an administrator, inactive or barred from logging in is + made a plain account again; +- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose one + scope is `read:repository`: whatever team it is put in, the forge refuses it a push, a comment and any token or + administration of its own (§5). Every ask mints anew; that is its rotation; +- answers git's credential-store lines for it — the forge's public origin, read from `origins` (the route's name), + and on the forge's own machine its address there too — sealed to the key the vault made for the call. A holder of + another module is not answered and said under `skipped`. + +The vault keeps each as `provision//gitea-read//claude-code` and hands it to that holder alone; +the claude-code module places it in the agent's home (ADR 0365 §4). The token is never logged, answered in the clear +or put in an error. `go test ./cmd/agent-token` runs it against a fake forge. + +## The agents' read teams (novox/hq ADR 0365 §3, issue 758) + +An agent's account reads more than public repositories only through a team, per repository, and only to read. Which +teams exist and who is in them is the operator's, and it is one trusted config value, `agent-read-teams`: +`{"": {"repos": ["/", ...], "members": ["", ...]}}`, a member `` being the account +`agent-`. The module renders it into `agent-teams.json` and runs `cmd/agent-teams` when it starts and +whenever that file changes. For each team it makes `agent-read-` in the organisation of the first repository's +owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the +listed accounts (an account that does not exist yet is skipped and said). A team named `agent-read-*` that has left +the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a +change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams` +runs it against a fake forge. + ## The package registry's retention (novox/hq ADR 0251 §4, to-be 51) Every publish adds a version to the registry, and until this nothing removed one. The Go bundle diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 4ac40fb9..bb8de0d2 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -8,6 +8,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js"; import { isCommit } from "./checks.js"; +import { TOOLS_ACCOUNT } from "./tools-account.js"; /** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every * call, whether the names came from a caller or from the forge's own answer. */ @@ -101,6 +102,94 @@ export interface OpenPull { /** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check * that started and never answered — pending — from one that answered. */ checks: Record; + /** The pull request's labels, by name. */ + labels?: string[]; + /** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at ", the + * head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */ + ready?: ReadyMark; +} + +/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */ +export const READY_LABEL = "mesh/ready"; + +/** A comment that names the head a pull request is marked ready at: "ready at ", on a line of its own. */ +const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im; + +/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */ +export interface ReadyMark { + head: string; + by?: string; + at?: string; + label_id: number; +} + +/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which + * the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors= …`. A file + * that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the + * account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */ +export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER, + read: (f: string) => string = (f) => readFileSync(f, "utf8"), + tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] { + const fallback = [...new Set([tools, own].filter((a): a is string => !!a))]; + if (!file) return fallback; + try { + const line = read(file).split("\n").find((l) => l.startsWith("authors=")); + const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean); + if (authors.length) return authors; + console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`); + } catch (err) { + console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` + + `${fallback.join(", ") || "none"} may mark a pull request ready`); + } + return fallback; +} + +/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a + * pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */ +export interface TimelineEvent { + id: number; + type: string; + body: string; + created_at?: string; + user?: string; + label?: { id: number; name: string }; +} + +/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */ +const ignoredSaid = new Set(); + +/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is + * one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at" + * comment counts only when its author is one of the accounts given, and only when it was written after the label + * mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off + * undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A + * comment by any other account is ignored, and said once. */ +export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[], + say: (line: string) => void = (l) => console.error(l)): ReadyMark { + const listed = new Set(authors.map((a) => a.toLowerCase())); + let labelled = false; + let mark: ReadyMark | undefined; + for (const e of [...events].sort((x, y) => x.id - y.id)) { + if (e.type === "pull_push") { + mark = undefined; + } else if (e.type === "label" && e.label?.name === READY_LABEL) { + labelled = e.body === "1"; + mark = undefined; + } else if (e.type === "comment") { + const m = READY_AT.exec(e.body); + if (!m) continue; + if (!listed.has(String(e.user ?? "").toLowerCase())) { + if (!ignoredSaid.has(e.id)) { + ignoredSaid.add(e.id); + say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` + + `only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`); + } + continue; + } + if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID }; + } + } + return mark ?? { head: "", label_id: labelID }; } /** One status on a head, the newest of its context: its state, what it said, and when it was set. */ @@ -332,8 +421,9 @@ export class GiteaClient { /** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given, * page by page (novox/hq issue 347): each with its repository and its own update time. */ - async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> { - const out: { owner: string; name: string; number: number; updated_at?: string }[] = []; + async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string; + labels?: { id: number; name: string }[] }[]> { + const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = []; for (let page = 1; page <= 20; page++) { const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) }); if (since) qs.set("since", since); @@ -343,7 +433,8 @@ export class GiteaClient { const [fo, fn] = String(r.full_name ?? "").split("/"); const owner = String(r.owner ?? fo ?? ""); const name = String(r.name ?? fn ?? ""); - if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at }); + const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") })); + if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels }); } if (!found || found.length < 50) break; } @@ -354,19 +445,26 @@ export class GiteaClient { * requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull * request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a * fault in the announcer cannot hide itself. Reads only. */ - async openPulls(): Promise { + async openPulls(readyAuthors: string[] = []): Promise { const out: OpenPull[] = []; - const rules = new Map(); + const rules = new Map(); for (const found of await this.searchOpenPulls()) { const full = `${found.owner}/${found.name}`; const pull = await this.getPullRequest(found.owner, found.name, found.number); if (pull.state !== "open" || !pull.head_sha) continue; - if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined)); - const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main")); + // The base's protection as the forge applies it to that branch (appliedProtection): readable without administration. + const base = `${full}:${pull.base || "main"}`; + if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined)); + const rule = rules.get(base); const checks = await this.headChecks(found.owner, found.name, pull.head_sha); + const labels = found.labels ?? []; + // The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label. + const label = labels.find((l) => l.name === READY_LABEL); + const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors) + : undefined; out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha, html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [], - statuses: Object.keys(checks), checks }); + statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) }); } return out; } @@ -580,16 +678,44 @@ export class GiteaClient { // ---- Branch protection ---- - /** A repository's branch protection rules. */ + /** A repository's branch protection rules. The forge answers them to a repository's administrator alone, which the + * tools' account is not (novox/hq ADR 0365 §2): for it this is refused (403), and what a branch's protection asks + * is read with appliedProtection. */ async branchProtections(owner: string, repo: string): Promise { return (await this.request(`${repoPath(owner, repo)}/branch_protections`)) ?? []; } - /** The rule named for a branch, null when it has none. */ + /** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */ async branchProtection(owner: string, repo: string, rule: string): Promise { return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null; } + /** + * The protection that covers an existing branch, as the forge applies it, or null when none does. Read from the + * branch itself, which anyone who reads the code may read — the rules' own list is a repository administrator's + * (novox/hq ADR 0365 §2) — and as the forge matches it, a glob rule included (its name is the rule's). The + * branch's `user_can_push` stands for `enable_push`: whether this account may push there. A branch that does not + * exist is null: nothing covers it yet, and the forge judges a push that makes it. + */ + async appliedProtection(owner: string, repo: string, branch: string): Promise { + let b: any; + try { + b = await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`); + } catch (err) { + if (/: 404 /.test(String((err as Error)?.message))) return null; + throw err; + } + if (!b?.protected) return null; + return { + rule_name: String(b.effective_branch_protection_name || branch), + branch_name: branch, + enable_push: Boolean(b.user_can_push), + enable_status_check: Boolean(b.enable_status_check), + status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [], + required_approvals: Number(b.required_approvals ?? 0), + }; + } + /** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the * branch is edited — its required statuses replaced by these, everything else it says kept unless * asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */ @@ -639,6 +765,45 @@ export class GiteaClient { return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`); } + /** **Whether a pull request carries an approving review at a head** (novox/hq ADR 0382 §6): a review of the forge that + * approves, is not dismissed and was made at that very head; or the mesh's ready mark naming that head (issue 722), which is + * how an agent's approval is read where the forge refuses an agent's approve. Reads only. */ + async approvedAt(owner: string, repo: string, index: number, head: string, authors?: string[]): Promise { + indexOf(index); + const want = head.toLowerCase(); + for (let page = 1; page <= 20; page++) { + const raw = await this.request(`${repoPath(owner, repo)}/pulls/${index}/reviews?page=${page}&limit=50`); + for (const r of raw ?? []) { + if (String(r?.state ?? "").toUpperCase() === "APPROVED" && !r?.dismissed && !r?.stale && + String(r?.commit_id ?? "").toLowerCase() === want) return true; + } + if (!raw || raw.length < 50) break; + } + const labels = await this.request(`${repoPath(owner, repo)}/issues/${index}/labels`); + const label = (labels ?? []).find((l) => l?.name === READY_LABEL); + if (!label) return false; + const ready = readyMarkOf(Number(label.id ?? 0), await this.timeline(owner, repo, index), authors ?? readyAuthors()); + return !!ready && ready.head.toLowerCase() === want; + } + + /** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long + * thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */ + async timeline(owner: string, repo: string, index: number): Promise { + indexOf(index); + const out: TimelineEvent[] = []; + for (let page = 1; page <= 100; page++) { + const raw = await this.request(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`); + for (const e of raw ?? []) { + out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""), + created_at: e?.created_at, user: e?.user?.login, + ...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) }); + } + if (!raw || raw.length < 50) return out; + } + console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`); + return out; + } + /** Every comment on an issue or pull request, oldest first. */ async listComments(owner: string, repo: string, index: number): Promise { indexOf(index); @@ -803,12 +968,26 @@ export class GiteaAdmin { return { status: res.status, body }; } + /** One admin-API call with a JSON body, answering the status and body (the system webhook, novox/hq issue 729). */ + call(method: string, path: string, body?: unknown): Promise { + return this.request(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + } + /** Fail with the forge's own message when a status the caller did not expect comes back. */ private static fail(path: string, res: AdminResponse): never { const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body); throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`); } + /** A repository's branch protection rules, read with the administrator account — the forge answers them to a + * repository's administrator alone, which the tools' account is not (novox/hq ADR 0365 §2, ADR 0375). Only a read. */ + async branchProtections(owner: string, repo: string): Promise { + const path = `${repoPath(owner, repo)}/branch_protections`; + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(path, res); + return Array.isArray(res.body) ? res.body : []; + } + /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent * create won the race) are all success. */ async ensureOrg(name: string): Promise { @@ -832,12 +1011,16 @@ export class GiteaAdmin { // unit the team does not have — so code read must be said here: without it gitea answers a // member's clone of a private repository with "not found", which is how the builder's first // credentialed clone failed against a team that named only packages. - const units = { + return this.ensureTeamWith(org, team, { permission: "read", units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, includes_all_repositories: true, can_create_org_repo: false, - }; + }); + } + + /** Ensure a team exists with exactly these units, and return its id (ensureTeam; the tools' team, tools-account.ts). */ + async ensureTeamWith(org: string, team: string, units: Record): Promise { const found = await this.findTeam(org, team); if (found !== null) { const patch = await this.request(`/teams/${found}`, { @@ -872,7 +1055,7 @@ export class GiteaAdmin { * The edit path is taken only when the user actually exists. A 422 from the create is also what * a plain validation failure returns, and reading it as "already there" made the follow-up edit * 404 — burying the create's own message, which is the one that says what is actually wrong. */ - async ensureUser(username: string, password: string, email: string): Promise { + async ensureUser(username: string, password: string, email: string, plain = false): Promise { const res = await this.request("/admin/users", { method: "POST", body: JSON.stringify({ username, email, password, must_change_password: false }), @@ -886,7 +1069,9 @@ export class GiteaAdmin { // login_name is required by the admin edit endpoint; for a local user it is the username. // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong // password, so the provisioner's check reports it lost; applying again must undo both. - body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }), + // plain: never a site administrator — one made so by hand is made a plain account again (novox/hq ADR 0365). + body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false, + ...(plain ? { admin: false } : {}) }), }); if (patch.status === 200) return; GiteaAdmin.fail(`/admin/users/${username}`, patch); diff --git a/modules/gitea/cmd/agent-teams/main.go b/modules/gitea/cmd/agent-teams/main.go new file mode 100644 index 00000000..eb92207c --- /dev/null +++ b/modules/gitea/cmd/agent-teams/main.go @@ -0,0 +1,42 @@ +// agent-teams: the agents' read teams on the forge (novox/hq ADR 0365 clause 3, issue 758). A program the node's +// runtime runs once when the module starts and again whenever the module's config value `agent-read-teams` changes: +// it reads the file the mesh renders from that value, and makes the forge's teams named agent-read-* match it. Each +// team reads only; its repositories and its members (agent accounts, agent-) are exactly the value's. It +// touches nothing outside the value: no team without the prefix, no repository or account of another kind. +// +// It says each change on stdout in one line, and never a secret. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "gitea/internal/forgehttp" +) + +func main() { + forge, missing := forgehttp.FromEnv() + file := os.Getenv("MESH_AGENT_TEAMS_FILE") + if file == "" { + missing = append(missing, "MESH_AGENT_TEAMS_FILE") + } + if len(missing) > 0 { + fmt.Fprintf(os.Stderr, "agent-teams: %v not set by the manifest; nothing was changed\n", missing) + os.Exit(1) + } + value, err := ReadValue(file) + if err != nil { + fmt.Fprintln(os.Stderr, "agent-teams:", err) + os.Exit(1) + } + s := &Syncer{Forge: forge, Log: func(format string, a ...any) { fmt.Printf(format+"\n", a...) }, + WaitFor: 3 * time.Minute, Poll: 5 * time.Second} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := s.Sync(ctx, value); err != nil { + fmt.Fprintln(os.Stderr, "agent-teams:", err) + os.Exit(1) + } +} diff --git a/modules/gitea/cmd/agent-teams/teams.go b/modules/gitea/cmd/agent-teams/teams.go new file mode 100644 index 00000000..5f2a0b6e --- /dev/null +++ b/modules/gitea/cmd/agent-teams/teams.go @@ -0,0 +1,339 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "sort" + "strings" + "time" + + "gitea/internal/forgehttp" +) + +const ( + // ConfigKey is the module's config value this program applies. + ConfigKey = "agent-read-teams" + // TeamPrefix names every team this program owns; a team without it is never touched (ADR 0365 §3). + TeamPrefix = "agent-read-" + // AccountPrefix names an agent's account for its node (cmd/agent-token AccountPrefix, ADR 0365 §1). + AccountPrefix = "agent-" + page = 50 +) + +var ( + teamName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$`) + nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + repoName = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)$`) +) + +// readUnits are the units a read team has, each at read: the forge's rights of a team that reads and nothing more. +var readUnits = map[string]string{ + "repo.code": "read", "repo.issues": "read", "repo.pulls": "read", "repo.releases": "read", "repo.wiki": "read", +} + +// Team is one read team of the value: its repositories (owner/repo, read only) and its members (nodes whose agent +// account, agent-, is in the team). +type Team struct { + Repos []string `json:"repos"` + Members []string `json:"members"` +} + +// Value is the config value: each team by the name it is given without the prefix. +type Value map[string]Team + +// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value, +// which empties every read team. +func ReadValue(path string) (Value, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("the teams file cannot be read (%s): %w", path, err) + } + var file map[string]json.RawMessage + if err := json.Unmarshal(raw, &file); err != nil { + return nil, fmt.Errorf("the teams file %s is not JSON: %w", path, err) + } + var v Value + if r, ok := file[ConfigKey]; ok { + dec := json.NewDecoder(strings.NewReader(string(r))) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("%s in %s is not {team: {repos, members}}: %w", ConfigKey, path, err) + } + } + return v, nil +} + +// Syncer makes the forge's agent-read-* teams the value's. +type Syncer struct { + Forge forgehttp.Client + Log func(format string, a ...any) + WaitFor time.Duration // how long to wait for the forge to answer; none when zero + Poll time.Duration +} + +type forgeTeam struct { + ID int64 `json:"id"` + Name string `json:"name"` + Permission string `json:"permission"` +} + +type forgeRepo struct { + FullName string `json:"full_name"` +} + +type forgeMember struct { + Login string `json:"login"` +} + +func (s *Syncer) say(format string, a ...any) { + if s.Log != nil { + s.Log(format, a...) + } +} + +// Sync applies the value. A team that cannot be applied is said and the others still are; the error names them. +func (s *Syncer) Sync(ctx context.Context, v Value) error { + if err := s.waitForForge(ctx); err != nil { + return err + } + orgs, err := s.orgs(ctx) + if err != nil { + return err + } + wanted := map[string]bool{} + var failed []string + names := make([]string, 0, len(v)) + for n := range v { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + wanted[TeamPrefix+n] = true + if err := s.apply(ctx, n, v[n]); err != nil { + s.say("not applied: team %s%s: %v", TeamPrefix, n, err) + failed = append(failed, TeamPrefix+n) + } + } + for _, org := range orgs { + teams, err := s.teams(ctx, org) + if err != nil { + return err + } + for _, t := range teams { + if !strings.HasPrefix(t.Name, TeamPrefix) || wanted[t.Name] { + continue + } + if err := s.sync(ctx, org, t, nil, nil); err != nil { + s.say("not applied: team %s in %s: %v", t.Name, org, err) + failed = append(failed, t.Name) + } + } + } + if len(failed) > 0 { + return fmt.Errorf("not applied: %s", strings.Join(failed, ", ")) + } + return nil +} + +func (s *Syncer) waitForForge(ctx context.Context) error { + deadline := time.Now().Add(s.WaitFor) + for { + err := s.Forge.GetJSON(ctx, "/api/v1/version", nil) + if err == nil { + return nil + } + var se *forgehttp.StatusError + if errors.As(err, &se) || time.Now().After(deadline) || ctx.Err() != nil { + return fmt.Errorf("the forge is not reachable: %w", err) + } + time.Sleep(s.Poll) + } +} + +// apply makes one team of the value. +func (s *Syncer) apply(ctx context.Context, name string, t Team) error { + if !teamName.MatchString(name) { + return fmt.Errorf("%q is not a team name", name) + } + if len(t.Repos) == 0 { + return errors.New("it lists no repository, so the organisation to make it in is not known") + } + org := "" + repos := map[string]bool{} + for _, r := range t.Repos { + m := repoName.FindStringSubmatch(r) + if m == nil { + return fmt.Errorf("%q is not owner/repo", r) + } + if org == "" { + org = m[1] + } + if m[1] != org { + return fmt.Errorf("%q is not in %s: a team's repositories are of one owner, the first's", r, org) + } + repos[r] = true + } + members := map[string]bool{} + for _, n := range t.Members { + if !nodeName.MatchString(n) { + return fmt.Errorf("%q is not a node's name", n) + } + members[AccountPrefix+n] = true + } + full := TeamPrefix + name + teams, err := s.teams(ctx, org) + if err != nil { + return err + } + var team *forgeTeam + for i := range teams { + if teams[i].Name == full { + team = &teams[i] + } + } + if team == nil { + var made forgeTeam + err := s.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+org+"/teams", map[string]any{ + "name": full, "description": "Read-only team of agents' forge accounts (ADR 0365 §3); set by the gitea config value " + ConfigKey, + "permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits, + }, &made) + if err != nil { + return fmt.Errorf("the team could not be made: %w", err) + } + s.say("made the team %s in %s, reading only", full, org) + team = &made + } else if team.Permission != "read" { + if err := s.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", team.ID), map[string]any{ + "name": full, "permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits, + }, nil); err != nil { + return fmt.Errorf("the team could not be made read-only: %w", err) + } + s.say("made the team %s in %s read-only (it was %s)", full, org, team.Permission) + } + return s.sync(ctx, org, *team, repos, members) +} + +// sync makes the team's repositories and members exactly the sets (nil is none). +func (s *Syncer) sync(ctx context.Context, org string, t forgeTeam, repos, members map[string]bool) error { + var errs []string + var haveRepos []forgeRepo + if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/repos", t.ID), &haveRepos); err != nil { + return err + } + held := map[string]bool{} + for _, r := range haveRepos { + held[r.FullName] = true + if !repos[r.FullName] { + if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r.FullName), nil, nil); err != nil { + errs = append(errs, err.Error()) + continue + } + s.say("took %s from the team %s", r.FullName, t.Name) + } + } + for _, r := range sortedKeys(repos) { + if held[r] { + continue + } + if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r), nil, nil); err != nil { + errs = append(errs, fmt.Sprintf("%s could not be given to the team: %v", r, err)) + continue + } + s.say("gave %s to the team %s, to read", r, t.Name) + } + var haveMembers []forgeMember + if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/members", t.ID), &haveMembers); err != nil { + return err + } + in := map[string]bool{} + for _, m := range haveMembers { + in[m.Login] = true + if !members[m.Login] { + if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m.Login)), nil, nil); err != nil { + errs = append(errs, err.Error()) + continue + } + s.say("took %s out of the team %s", m.Login, t.Name) + } + } + for _, m := range sortedKeys(members) { + if in[m] { + continue + } + if err := s.Forge.GetJSON(ctx, "/api/v1/users/"+url.PathEscape(m), nil); err != nil { + if errors.Is(err, forgehttp.ErrNotFound) { + s.say("skipped %s for the team %s: the account does not exist yet", m, t.Name) + continue + } + errs = append(errs, err.Error()) + continue + } + if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m)), nil, nil); err != nil { + errs = append(errs, fmt.Sprintf("%s could not be put in the team: %v", m, err)) + continue + } + s.say("put %s in the team %s", m, t.Name) + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + return nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// list reads every page of a list answer into into (a pointer to a slice). +func (s *Syncer) list(ctx context.Context, path string, into any) error { + var all []json.RawMessage + for p := 1; ; p++ { + var got []json.RawMessage + if err := s.Forge.GetJSON(ctx, fmt.Sprintf("%s?limit=%d&page=%d", path, page, p), &got); err != nil { + return err + } + all = append(all, got...) + if len(got) < page { + break + } + } + raw, _ := json.Marshal(all) + return json.Unmarshal(raw, into) +} + +func (s *Syncer) teams(ctx context.Context, org string) ([]forgeTeam, error) { + var out []forgeTeam + if err := s.list(ctx, "/api/v1/orgs/"+org+"/teams", &out); err != nil { + if errors.Is(err, forgehttp.ErrNotFound) { + return nil, fmt.Errorf("the organisation %s does not exist on the forge", org) + } + return nil, err + } + return out, nil +} + +// orgs are the organisations on the forge, where a read team no longer in the value is found. +func (s *Syncer) orgs(ctx context.Context) ([]string, error) { + var out []struct { + Name string `json:"name"` + } + if err := s.list(ctx, "/api/v1/admin/orgs", &out); err != nil { + return nil, fmt.Errorf("the forge's organisations could not be read: %w", err) + } + names := make([]string, len(out)) + for i, o := range out { + names[i] = o.Name + } + return names, nil +} diff --git a/modules/gitea/cmd/agent-teams/teams_test.go b/modules/gitea/cmd/agent-teams/teams_test.go new file mode 100644 index 00000000..0dfe617c --- /dev/null +++ b/modules/gitea/cmd/agent-teams/teams_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "testing" + + "gitea/internal/forgehttp" +) + +type fTeam struct { + ID int64 + Org string + Name string + Perm string + Units map[string]string + Repos map[string]bool + Members map[string]bool +} + +type fakeForge struct { + mu sync.Mutex + orgs []string + users map[string]bool + teams []*fTeam + next int64 +} + +func newFake(orgs []string, users ...string) *fakeForge { + f := &fakeForge{orgs: orgs, users: map[string]bool{}, next: 1} + for _, u := range users { + f.users[u] = true + } + return f +} + +func (f *fakeForge) add(org, name, perm string, repos, members []string) *fTeam { + t := &fTeam{ID: f.next, Org: org, Name: name, Perm: perm, Repos: map[string]bool{}, Members: map[string]bool{}} + f.next++ + for _, r := range repos { + t.Repos[r] = true + } + for _, m := range members { + t.Members[m] = true + } + f.teams = append(f.teams, t) + return t +} + +func (f *fakeForge) byName(org, name string) *fTeam { + for _, t := range f.teams { + if t.Org == org && t.Name == name { + return t + } + } + return nil +} + +var ( + orgTeams = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/teams$`) + teamPath = regexp.MustCompile(`^/api/v1/teams/(\d+)$`) + teamRepos = regexp.MustCompile(`^/api/v1/teams/(\d+)/repos(?:/(.+))?$`) + teamMems = regexp.MustCompile(`^/api/v1/teams/(\d+)/members(?:/(.+))?$`) +) + +func (f *fakeForge) find(id string) *fTeam { + n, _ := strconv.ParseInt(id, 10, 64) + for _, t := range f.teams { + if t.ID == n { + return t + } + } + return nil +} + +func keys(m map[string]bool) []string { + out := []string{} + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if u, p, ok := r.BasicAuth(); !ok || u != "mesh-admin" || p != "pw" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + reply := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + paged := r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1" + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + p := r.URL.Path + switch { + case p == "/api/v1/version": + reply(200, map[string]string{"version": "1"}) + case p == "/api/v1/admin/orgs": + out := []map[string]string{} + for _, o := range f.orgs { + out = append(out, map[string]string{"name": o}) + } + if paged { + out = nil + } + reply(200, out) + case strings.HasPrefix(p, "/api/v1/users/"): + if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] { + reply(404, nil) + return + } + reply(200, map[string]string{}) + case orgTeams.MatchString(p): + org := orgTeams.FindStringSubmatch(p)[1] + known := false + for _, o := range f.orgs { + known = known || o == org + } + if !known { + reply(404, nil) + return + } + if r.Method == http.MethodPost { + units, _ := in["units_map"].(map[string]any) + t := f.add(org, in["name"].(string), in["permission"].(string), nil, nil) + t.Units = map[string]string{} + for k, v := range units { + t.Units[k] = v.(string) + } + reply(201, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm}) + return + } + out := []map[string]any{} + for _, t := range f.teams { + if t.Org == org && !paged { + out = append(out, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm}) + } + } + reply(200, out) + case teamPath.MatchString(p): + t := f.find(teamPath.FindStringSubmatch(p)[1]) + t.Perm = in["permission"].(string) + reply(200, nil) + case teamRepos.MatchString(p): + m := teamRepos.FindStringSubmatch(p) + t := f.find(m[1]) + switch { + case m[2] == "" && r.Method == http.MethodGet: + out := []map[string]string{} + for _, k := range keys(t.Repos) { + out = append(out, map[string]string{"full_name": k}) + } + if paged { + out = nil + } + reply(200, out) + case r.Method == http.MethodPut: + t.Repos[m[2]] = true + reply(204, nil) + case r.Method == http.MethodDelete: + delete(t.Repos, m[2]) + reply(204, nil) + } + case teamMems.MatchString(p): + m := teamMems.FindStringSubmatch(p) + t := f.find(m[1]) + switch { + case m[2] == "" && r.Method == http.MethodGet: + out := []map[string]string{} + for _, k := range keys(t.Members) { + out = append(out, map[string]string{"login": k}) + } + if paged { + out = nil + } + reply(200, out) + case r.Method == http.MethodPut: + t.Members[m[2]] = true + reply(204, nil) + case r.Method == http.MethodDelete: + delete(t.Members, m[2]) + reply(204, nil) + } + default: + reply(404, nil) + } +} + +func syncer(t *testing.T, f *fakeForge) (*Syncer, *[]string) { + t.Helper() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + pw := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(pw, []byte("pw\n"), 0o600); err != nil { + t.Fatal(err) + } + var said []string + return &Syncer{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, + Log: func(format string, a ...any) { said = append(said, sprintf(format, a...)) }}, &said +} + +func sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) } + +func equal(a, b []string) bool { return strings.Join(a, ",") == strings.Join(b, ",") } + +// The first test of issue 758: a value makes a read-only team with exactly its repositories and members; a changed +// value takes a repository and a member away; a team that is not named agent-read-* is never touched. +func TestValueMakesReadTeamAndChangeRemoves(t *testing.T) { + f := newFake([]string{"novox"}, "agent-g14", "agent-ace") + other := f.add("novox", "Owners", "owner", []string{"novox/hq"}, []string{"agent-ace"}) + s, said := syncer(t, f) + ctx := context.Background() + + err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}}) + if err != nil { + t.Fatal(err) + } + team := f.byName("novox", "agent-read-all") + if team == nil || team.Perm != "read" { + t.Fatalf("team = %+v, want a read team", team) + } + for _, u := range team.Units { + if u != "read" { + t.Errorf("unit %q, want read only", u) + } + } + if !equal(keys(team.Repos), []string{"novox/hq", "novox/mesh-catalog"}) || !equal(keys(team.Members), []string{"agent-g14"}) { + t.Fatalf("repos %v members %v", keys(team.Repos), keys(team.Members)) + } + + before := len(*said) + if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}}); err != nil { + t.Fatal(err) + } + if len(*said) != before { + t.Errorf("a second run changed something: %v", (*said)[before:]) + } + + if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq"}, Members: nil}}); err != nil { + t.Fatal(err) + } + if !equal(keys(team.Repos), []string{"novox/hq"}) || len(team.Members) != 0 { + t.Fatalf("after change: repos %v members %v", keys(team.Repos), keys(team.Members)) + } + + // the team leaves the value: emptied, never deleted + if err := s.Sync(ctx, Value{}); err != nil { + t.Fatal(err) + } + if f.byName("novox", "agent-read-all") == nil || len(team.Repos) != 0 || len(team.Members) != 0 { + t.Fatalf("removed team not emptied: %+v", team) + } + if other.Perm != "owner" || !equal(keys(other.Repos), []string{"novox/hq"}) || !equal(keys(other.Members), []string{"agent-ace"}) { + t.Fatalf("a team without the prefix was touched: %+v", other) + } + for _, line := range *said { + if strings.Contains(line, "pw") { + t.Errorf("a line names the password: %q", line) + } + } +} + +func TestPermissionIsCorrectedAndMissingAccountSkipped(t *testing.T) { + f := newFake([]string{"novox"}, "agent-g14") + team := f.add("novox", "agent-read-all", "write", nil, nil) + s, said := syncer(t, f) + err := s.Sync(context.Background(), Value{"all": {Repos: []string{"novox/hq"}, Members: []string{"g14", "later"}}}) + if err != nil { + t.Fatal(err) + } + if team.Perm != "read" || !equal(keys(team.Members), []string{"agent-g14"}) { + t.Fatalf("team = %+v", team) + } + if !strings.Contains(strings.Join(*said, "\n"), "agent-later") { + t.Errorf("the missing account was not said: %v", *said) + } +} + +func TestRefusesWhatIsNotAReadTeam(t *testing.T) { + f := newFake([]string{"novox", "other"}) + s, _ := syncer(t, f) + for name, v := range map[string]Value{ + "no repo": {"a": {Members: []string{"g14"}}}, + "two owners": {"a": {Repos: []string{"novox/hq", "other/x"}}}, + "bad repo": {"a": {Repos: []string{"hq"}}}, + "bad member": {"a": {Repos: []string{"novox/hq"}, Members: []string{"../x"}}}, + "unknown org": {"a": {Repos: []string{"nowhere/hq"}}}, + "bad teamname": {"a b": {Repos: []string{"novox/hq"}}}, + } { + if err := s.Sync(context.Background(), v); err == nil { + t.Errorf("%s: no error", name) + } + } + if len(f.teams) != 0 { + t.Errorf("teams made: %+v", f.teams) + } +} + +func TestReadValue(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "t.json") + _ = os.WriteFile(p, []byte(`{"agent-read-teams":{"a":{"repos":["o/r"],"members":["g14"]}}}`), 0o600) + v, err := ReadValue(p) + if err != nil || len(v["a"].Repos) != 1 || v["a"].Members[0] != "g14" { + t.Fatalf("%v %v", v, err) + } + _ = os.WriteFile(p, []byte(`{}`), 0o600) + if v, err := ReadValue(p); err != nil || len(v) != 0 { + t.Fatalf("%v %v", v, err) + } + _ = os.WriteFile(p, []byte(`{"agent-read-teams":{"a":{"repos":["o/r"],"write":true}}}`), 0o600) + if _, err := ReadValue(p); err == nil { + t.Fatal("an unknown field was accepted") + } +} diff --git a/modules/gitea/cmd/agent-token/main.go b/modules/gitea/cmd/agent-token/main.go new file mode 100644 index 00000000..92f5a6d8 --- /dev/null +++ b/modules/gitea/cmd/agent-token/main.go @@ -0,0 +1,72 @@ +// agent-token: the forge's read credential for each node's agent (novox/hq ADR 0365 clauses 1, 3, 4 and 5; issue +// 737). A process the node's runtime launches and speaks MCP over stdio to, beside the module's other bundles. +// +// It serves one tool, gitea_agent_token, which the vault alone asks (ADR 0349 clause 10, issue 749): for each holder +// of the provision `gitea-read` the vault names — the claude-code module on one machine — it makes that machine's +// agent a forge account of its own, `agent-`, no administrator and in no team, mints it a token whose one +// scope is `read:repository`, and answers the git credential lines for it, each holder's sealed to the key the vault +// made for the call. Asked again, it mints again and deletes the account's token from before: that is its rotation. +// +// stdout is the protocol; it says what it says on stderr, and never a token or the admin's password. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the npm-registry bundle +// for why); the README names this one, and a test holds the three together. +var ToolNames = []string{ToolName} + +func main() { + if err := stdio.Serve("", Tools(MinterFromEnv)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// Tools are the bundle's tools over the minter the function gives, asked per call, so a manifest that does not set +// the environment is a refusal naming what is missing, not a bundle that will not start. +func Tools(minter func() (*Minter, error)) []stdio.Tool { + return []stdio.Tool{{ + Name: ToolName, + Description: "The forge's read credential for each node's agent (novox/hq ADR 0365), answered to the vault " + + "alone: for each holder named, a forge account of the node's agent's own (agent-), no administrator " + + "and in no team, and a token of scope read:repository minted for it anew, its git credential lines " + + "sealed to seal_to. Any other caller is refused and nothing is minted.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "provision": map[string]any{"type": "string", "description": "the provision, " + Provision}, + "seal_to": map[string]any{"type": "string", "description": "the key made for this call, base64"}, + "holders": map[string]any{ + "type": "array", + "description": "the provision's holders: each a consuming module on one machine", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "node": map[string]any{"type": "string"}, + "module": map[string]any{"type": "string"}, + }}, + }, + }, + "required": []string{"provision", "seal_to", "holders"}, + }, + Run: func(args map[string]any) (any, error) { + caller := stdio.CallerOf(args) + if err := fromTheVault(caller); err != nil { + return nil, err + } + ask, err := readAsk(args) + if err != nil { + return nil, err + } + m, err := minter() + if err != nil { + return nil, err + } + return m.Answer(ask) + }, + }} +} diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go new file mode 100644 index 00000000..33e8de9e --- /dev/null +++ b/modules/gitea/cmd/agent-token/token.go @@ -0,0 +1,348 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/crypto/nacl/box" + + "gitea/internal/forgehttp" +) + +const ( + // ToolName is the issuing tool the vault asks (the offer's credential tool in module.json). + ToolName = "gitea_agent_token" + // Provision is what the tool issues: the forge's read credential, one per agent. + Provision = "gitea-read" + // Holder is the one consuming module answered: the agent's own module, which places the credential in the + // agent's home (ADR 0365 §4, ADR 0266). A holder of another module is not answered and keeps nothing. + Holder = "claude-code" + // TokenName is the token's name in the agent account's list, found and deleted by it at the next mint. + TokenName = "mesh-agent-read" + // AccountPrefix names an agent's account for its node (ADR 0365 §1). + AccountPrefix = "agent-" + // vaultModule is the vault's module: its account is `.mesh-vault` (ADR 0259 §8's runs-as). + vaultModule = "mesh-vault" + // deadline bounds one call, inside the vault's ask. + deadline = 45 * time.Second +) + +// TokenScopes are the only scopes an agent's token carries (ADR 0365 §4, §5): it reads repositories and nothing +// else, so the forge refuses it a push, a comment, a token of its own and any administration — whatever team the +// account is in. +var TokenScopes = []string{"read:repository"} + +var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + +// fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this +// tool would hold every agent's credential. +func fromTheVault(caller string) error { + node, module, ok := strings.Cut(caller, ".") + if ok && module == vaultModule && nodeName.MatchString(node) { + return nil + } + return fmt.Errorf("%s answers the vault alone (novox/hq ADR 0349 clause 10, ADR 0365); %q is not the vault, and "+ + "nothing was minted", ToolName, caller) +} + +// HolderAsk is one holder the vault named. +type HolderAsk struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// Ask is one call: the holders, and the key to seal each answer to. +type Ask struct { + SealTo [32]byte + Holders []HolderAsk +} + +// readAsk reads the call's arguments, refusing anything that is not the vault's per-holder ask. +func readAsk(args map[string]any) (Ask, error) { + if p, _ := args["provision"].(string); p != Provision { + return Ask{}, fmt.Errorf("%s issues %s alone, not %q; nothing was minted", ToolName, Provision, p) + } + key, _ := args["seal_to"].(string) + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key)) + if err != nil || len(raw) != 32 { + return Ask{}, errors.New("seal_to is not the key of a call; nothing was minted") + } + var out Ask + copy(out.SealTo[:], raw) + list, ok := args["holders"].([]any) + if !ok || len(list) == 0 { + return Ask{}, fmt.Errorf("%s answers a value for each holder, and none was named; nothing was minted", ToolName) + } + seen := map[HolderAsk]bool{} + for _, item := range list { + m, _ := item.(map[string]any) + node, _ := m["node"].(string) + module, _ := m["module"].(string) + h := HolderAsk{Node: strings.TrimSpace(node), Module: strings.TrimSpace(module)} + if h.Node == "" || h.Module == "" || seen[h] { + return Ask{}, errors.New("each holder is a node and a module, named once; nothing was minted") + } + seen[h] = true + out.Holders = append(out.Holders, h) + } + return out, nil +} + +// Minter mints the agents' tokens with the administrator's login, which sets the forge up and nothing else (ADR +// 0365 §2). +type Minter struct { + Forge forgehttp.Client + // Node is the forge's own machine: a holder there also reaches the forge at Forge.URL. + Node string + // OriginsFile holds the forge's public origins, one per line (https://): the origins every + // holder's credential is for. + OriginsFile string + // Password makes the password an agent's account is created with: random, sent once and kept nowhere. + Password func() string + Log func(format string, a ...any) +} + +// MinterFromEnv is the minter as the manifest hands it to this bundle. It builds the administrator's client itself: +// the module's words reach all its bundles, and a word naming another bundle's token must not make this one act as +// another account. +func MinterFromEnv() (*Minter, error) { + m := &Minter{ + Forge: forgehttp.Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), + User: os.Getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), + Timeout: 20 * time.Second}, + Node: os.Getenv("MESH_NODE"), + OriginsFile: os.Getenv("MESH_GITEA_ORIGINS_FILE"), + } + var missing []string + for _, kv := range [][2]string{{"MESH_GITEA_URL", m.Forge.URL}, {"MESH_GITEA_ADMIN_USER", m.Forge.User}, + {"MESH_GITEA_ADMIN_PASSWORD_FILE", m.Forge.PasswordFile}, {"MESH_GITEA_ORIGINS_FILE", m.OriginsFile}} { + if kv[1] == "" { + missing = append(missing, kv[0]) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest; nothing was minted", strings.Join(missing, ", ")) + } + return m, nil +} + +// Sealed is one holder's credential as the vault takes it. +type Sealed struct { + Node string `json:"node"` + Module string `json:"module"` + Sealed string `json:"sealed"` +} + +// Answer is what the tool answers: a sealed credential for each holder answered, and why each other was not. Never +// a token. +type Answer struct { + Holders []Sealed `json:"holders"` + Skipped map[string]string `json:"skipped,omitempty"` +} + +// one call at a time: a mint deletes the account's token from before, and two at once would delete each other's. +var minting sync.Mutex + +// Answer mints each holder's token and seals its credential lines. A holder that is not an agent's module is not +// answered; a failure for one holder fails the call, so the vault keeps nothing rather than a part. +func (m *Minter) Answer(ask Ask) (Answer, error) { + minting.Lock() + defer minting.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), deadline) + defer cancel() + origins, err := m.origins() + if err != nil { + return Answer{}, err + } + out := Answer{Holders: []Sealed{}} + skip := func(h HolderAsk, why string) { + if out.Skipped == nil { + out.Skipped = map[string]string{} + } + out.Skipped[h.Module+" on "+h.Node] = why + } + for _, h := range ask.Holders { + if h.Module != Holder { + skip(h, fmt.Sprintf("only %s places the forge's read credential, in its agent's home (ADR 0365 §4)", Holder)) + continue + } + if !nodeName.MatchString(h.Node) { + skip(h, "not a machine's name an account can be named for") + continue + } + reach := origins + if h.Node == m.Node { + reach = append(append([]string{}, origins...), m.Forge.URL) + } + if len(reach) == 0 { + skip(h, "the forge names no origin it is reached at from that machine") + continue + } + account := AccountPrefix + h.Node + if err := m.ensureAccount(ctx, account); err != nil { + return Answer{}, err + } + token, err := m.mint(ctx, account) + if err != nil { + return Answer{}, err + } + lines, err := credentialLines(account, token, reach) + token = "" + if err != nil { + return Answer{}, err + } + sealed, err := box.SealAnonymous(nil, []byte(lines), &ask.SealTo, rand.Reader) + lines = "" + if err != nil { + return Answer{}, fmt.Errorf("the credential for %s could not be sealed: %w", account, err) + } + out.Holders = append(out.Holders, Sealed{Node: h.Node, Module: h.Module, Sealed: base64.StdEncoding.EncodeToString(sealed)}) + m.say("minted a read token for %s (%s on %s); its token from before is deleted", account, h.Module, h.Node) + } + return out, nil +} + +// origins are the forge's public origins, as its route names them. +func (m *Minter) origins() ([]string, error) { + raw, err := os.ReadFile(m.OriginsFile) + if err != nil { + return nil, fmt.Errorf("the forge's origins cannot be read (%s); nothing was minted: %w", m.OriginsFile, err) + } + var out []string + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimRight(strings.TrimSpace(line), "/") + if line == "" { + continue + } + u, err := url.Parse(line) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" { + return nil, fmt.Errorf("%s names %q, which is not an origin (scheme://host); nothing was minted", m.OriginsFile, line) + } + out = append(out, line) + } + return out, nil +} + +// credentialLines are the lines of git's credential store for one account at each origin: what the agent's module +// places, and git reads by protocol and host. +func credentialLines(account, token string, origins []string) (string, error) { + seen := map[string]bool{} + var lines []string + for _, o := range origins { + u, err := url.Parse(strings.TrimRight(o, "/")) + if err != nil || u.Host == "" { + return "", fmt.Errorf("%q is not an origin", o) + } + u.Path, u.RawQuery, u.Fragment = "", "", "" + u.User = url.UserPassword(account, token) + if line := u.String(); !seen[line] { + seen[line] = true + lines = append(lines, line) + } + } + sort.Strings(lines) + return strings.Join(lines, "\n") + "\n", nil +} + +// gitea's account as the admin reads it. +type forgeUser struct { + IsAdmin bool `json:"is_admin"` + Active bool `json:"active"` + ProhibitLogin bool `json:"prohibit_login"` + Restricted bool `json:"restricted"` +} + +// ensureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never +// an administrator, active, able to use its token. It adds it to no team: what it may read beyond public +// repositories is the operator's to grant (ADR 0365 §3). +func (m *Minter) ensureAccount(ctx context.Context, account string) error { + var u forgeUser + err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) + switch { + case errors.Is(err, forgehttp.ErrNotFound): + password := m.password() + err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "username": account, "email": account + "@noreply.localhost", "password": password, + "must_change_password": false, "visibility": "private", + }, nil) + password = "" + if err != nil { + return fmt.Errorf("the account %s could not be made: %w", account, err) + } + m.say("made the forge account %s for that machine's agent, in no team", account) + return nil + case err != nil: + return fmt.Errorf("the account %s could not be read: %w", account, err) + } + if u.IsAdmin || !u.Active || u.ProhibitLogin { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ + "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, + }, nil); err != nil { + return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) + } + m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", + account, u.IsAdmin, u.Active, u.ProhibitLogin) + } + return nil +} + +type forgeToken struct { + ID int64 `json:"id"` + Name string `json:"name"` + SHA1 string `json:"sha1"` +} + +// mint deletes every token of the account by this module's name, by its id (a name held twice is cleared, not +// refused), and mints one with the read scope alone. +func (m *Minter) mint(ctx context.Context, account string) (string, error) { + var held []forgeToken + if err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account+"/tokens?limit=50", &held); err != nil { + return "", fmt.Errorf("the tokens of %s could not be read: %w", account, err) + } + for _, t := range held { + if t.Name != TokenName { + continue + } + if err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/users/%s/tokens/%d", account, t.ID), nil, nil); err != nil && + !errors.Is(err, forgehttp.ErrNotFound) { + return "", fmt.Errorf("the token of %s from before could not be deleted: %w", account, err) + } + } + var made forgeToken + if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/users/"+account+"/tokens", + map[string]any{"name": TokenName, "scopes": TokenScopes}, &made); err != nil { + return "", fmt.Errorf("no token could be minted for %s: %w", account, err) + } + if strings.TrimSpace(made.SHA1) == "" { + return "", fmt.Errorf("the forge answered no token for %s", account) + } + return strings.TrimSpace(made.SHA1), nil +} + +func (m *Minter) password() string { + if m.Password != nil { + return m.Password() + } + b := make([]byte, 24) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (m *Minter) say(format string, a ...any) { + if m.Log != nil { + m.Log(format, a...) + return + } + fmt.Fprintf(os.Stderr, format+"\n", a...) +} diff --git a/modules/gitea/cmd/agent-token/token_test.go b/modules/gitea/cmd/agent-token/token_test.go new file mode 100644 index 00000000..3f12dc4d --- /dev/null +++ b/modules/gitea/cmd/agent-token/token_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/nacl/box" + + "gitea/internal/forgehttp" +) + +const adminPassword = "admin-password-never-said" + +// fakeForge is gitea's API as far as this bundle uses it, as the administrator's login reaches it. +type fakeForge struct { + mu sync.Mutex + users map[string]*forgeUser + tokens map[string][]forgeToken + next int64 + calls []string + made map[string]map[string]any + minted map[string][]string // account -> scopes of its last mint + failMint bool +} + +func newFakeForge() *fakeForge { + return &fakeForge{users: map[string]*forgeUser{}, tokens: map[string][]forgeToken{}, made: map[string]map[string]any{}, + minted: map[string][]string{}, next: 1} +} + +var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`) + +func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + user, pass, ok := r.BasicAuth() + if !ok || user != "mesh-admin" || pass != adminPassword { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + body, _ := io.ReadAll(r.Body) + var in map[string]any + _ = json.Unmarshal(body, &in) + reply := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/users/") && strings.Count(r.URL.Path, "/") == 4: + u, ok := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/users/")] + if !ok { + reply(404, map[string]any{"message": "user does not exist"}) + return + } + reply(200, map[string]any{"is_admin": u.IsAdmin, "active": u.Active, "prohibit_login": u.ProhibitLogin}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": + name, _ := in["username"].(string) + f.users[name] = &forgeUser{Active: true} + f.made[name] = in + reply(201, map[string]any{"login": name}) + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"): + u := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/admin/users/")] + u.IsAdmin, _ = in["admin"].(bool) + u.Active, _ = in["active"].(bool) + u.ProhibitLogin, _ = in["prohibit_login"].(bool) + reply(200, map[string]any{}) + case tokenPath.MatchString(r.URL.Path): + m := tokenPath.FindStringSubmatch(r.URL.Path) + account := m[1] + switch r.Method { + case http.MethodGet: + reply(200, f.tokens[account]) + case http.MethodDelete: + kept := f.tokens[account][:0] + for _, t := range f.tokens[account] { + if fmt.Sprint(t.ID) != m[2] { + kept = append(kept, t) + } + } + f.tokens[account] = kept + reply(204, nil) + case http.MethodPost: + if f.failMint { + reply(500, map[string]any{"message": "the forge is unwell"}) + return + } + name, _ := in["name"].(string) + for _, t := range f.tokens[account] { + if t.Name == name { + reply(400, map[string]any{"message": "access token name has been used already"}) + return + } + } + var scopes []string + for _, s := range in["scopes"].([]any) { + scopes = append(scopes, s.(string)) + } + f.minted[account] = scopes + b := make([]byte, 20) + _, _ = rand.Read(b) + t := forgeToken{ID: f.next, Name: name, SHA1: fmt.Sprintf("%x", b)} + f.next++ + f.tokens[account] = append(f.tokens[account], forgeToken{ID: t.ID, Name: t.Name}) + reply(201, t) + } + default: + reply(404, map[string]any{"message": "not here: " + r.URL.Path}) + } +} + +type harness struct { + forge *fakeForge + server *httptest.Server + minter *Minter + public *[32]byte + private *[32]byte + logged *[]string +} + +func newHarness(t *testing.T) harness { + t.Helper() + f := newFakeForge() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + dir := t.TempDir() + pw := filepath.Join(dir, "admin.secret") + origins := filepath.Join(dir, "origins") + if err := os.WriteFile(pw, []byte(adminPassword+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(origins, []byte("https://git.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + var logged []string + m := &Minter{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Node: "anchor", + OriginsFile: origins, Log: func(format string, a ...any) { logged = append(logged, fmt.Sprintf(format, a...)) }} + return harness{forge: f, server: srv, minter: m, public: pub, private: priv, logged: &logged} +} + +func (h harness) open(t *testing.T, sealed string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + t.Fatal(err) + } + out, ok := box.OpenAnonymous(nil, raw, h.public, h.private) + if !ok { + t.Fatal("a holder's value is not sealed to the call's key") + } + return string(out) +} + +func (h harness) args(holders ...HolderAsk) map[string]any { + list := []any{} + for _, x := range holders { + list = append(list, map[string]any{"node": x.Node, "module": x.Module}) + } + return map[string]any{"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]), "holders": list} +} + +func TestOnlyTheVaultIsAnsweredAndNothingIsMintedForAnyoneElse(t *testing.T) { + h := newHarness(t) + for _, caller := range []string{"controller", "novox.node-tools", "provisioner.anchor", "anchor.claude-code", + "mesh-vault", "anchor.mesh-vault.x", "", "unproven"} { + if err := fromTheVault(caller); err == nil { + t.Errorf("%q was taken for the vault", caller) + } + } + if err := fromTheVault("novox.mesh-vault"); err != nil { + t.Errorf("the vault was refused: %v", err) + } + // Through the tool as the runtime serves it: a call no runtime named a caller for is the vault's to nobody. + tools := Tools(func() (*Minter, error) { return h.minter, nil }) + if _, err := tools[0].Run(h.args(HolderAsk{"anchor", "claude-code"})); err == nil || !strings.Contains(err.Error(), "vault alone") { + t.Fatalf("an unproven caller was answered: %v", err) + } + if len(h.forge.calls) != 0 { + t.Fatalf("the forge was reached for a caller that is not the vault: %v", h.forge.calls) + } +} + +func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.T) { + h := newHarness(t) + ask, err := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}, HolderAsk{"laptop", "lab"})) + if err != nil { + t.Fatal(err) + } + got, err := h.minter.Answer(ask) + if err != nil { + t.Fatal(err) + } + if len(got.Holders) != 2 { + t.Fatalf("answered %d holders, want the two agents: %+v", len(got.Holders), got) + } + if !strings.Contains(got.Skipped["lab on laptop"], Holder) { + t.Errorf("a holder that is not an agent's module was not said: %v", got.Skipped) + } + lines := map[string]string{} + for _, s := range got.Holders { + lines[s.Node] = h.open(t, s.Sealed) + } + anchorTok := tokenIn(t, lines["anchor"], "agent-anchor") + laptopTok := tokenIn(t, lines["laptop"], "agent-laptop") + if anchorTok == laptopTok { + t.Fatal("two agents were handed one token") + } + // The forge's own machine reaches it at its own address too; another machine only at its public origin. + if !strings.Contains(lines["anchor"], "https://agent-anchor:"+anchorTok+"@git.example.test\n") || + !strings.Contains(lines["anchor"], strings.Replace(h.server.URL, "http://", "http://agent-anchor:"+anchorTok+"@", 1)) { + t.Errorf("anchor's credential lines: %q", strings.ReplaceAll(lines["anchor"], anchorTok, "")) + } + if lines["laptop"] != "https://agent-laptop:"+laptopTok+"@git.example.test\n" { + t.Errorf("laptop's credential lines: %q", strings.ReplaceAll(lines["laptop"], laptopTok, "")) + } + for _, account := range []string{"agent-anchor", "agent-laptop"} { + u := h.forge.users[account] + if u == nil || u.IsAdmin { + t.Errorf("%s: %+v, want a plain account", account, u) + } + if made := h.forge.made[account]; made["must_change_password"] != false || made["password"] == "" { + t.Errorf("%s was made with %v", account, made) + } + if !slices.Equal(h.forge.minted[account], []string{"read:repository"}) { + t.Errorf("%s's token carries %v, want read:repository alone", account, h.forge.minted[account]) + } + } + for _, c := range h.forge.calls { + if strings.Contains(c, "/teams") || strings.Contains(c, "/orgs/") || strings.Contains(c, "collaborators") { + t.Errorf("an agent's account was given access (%s): it starts in no team, the operator grants (ADR 0365 §3)", c) + } + } + if _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 { + t.Errorf("accounts made: %v", h.forge.users) + } + said := strings.Join(*h.logged, "\n") + if strings.Contains(said, anchorTok) || strings.Contains(said, laptopTok) { + t.Fatal("a token was said in the log") + } +} + +func TestAskedAgainTheTokenIsMintedAnewAndTheOneFromBeforeDeleted(t *testing.T) { + h := newHarness(t) + ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"})) + first, err := h.minter.Answer(ask) + if err != nil { + t.Fatal(err) + } + h.forge.tokens["agent-laptop"] = append(h.forge.tokens["agent-laptop"], forgeToken{ID: 99, Name: "a-persons-own"}) + again, err := h.minter.Answer(ask) + if err != nil { + t.Fatal(err) + } + if h.open(t, first.Holders[0].Sealed) == h.open(t, again.Holders[0].Sealed) { + t.Fatal("asked again, the same token was answered: it was not rotated") + } + var names []string + for _, tok := range h.forge.tokens["agent-laptop"] { + names = append(names, tok.Name) + } + slices.Sort(names) + if !slices.Equal(names, []string{"a-persons-own", TokenName}) { + t.Fatalf("the account holds %v: the token from before is not deleted, or another was", names) + } +} + +func TestAnAccountMadeAnAdministratorByHandIsMadePlainAgain(t *testing.T) { + h := newHarness(t) + h.forge.users["agent-laptop"] = &forgeUser{IsAdmin: true, Active: true} + ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"})) + if _, err := h.minter.Answer(ask); err != nil { + t.Fatal(err) + } + if h.forge.users["agent-laptop"].IsAdmin { + t.Fatal("the agent's account is still an administrator") + } +} + +func TestAFailureSaysNeitherTheAdminsPasswordNorATokenAndAnswersNothing(t *testing.T) { + h := newHarness(t) + h.forge.failMint = true + ask, _ := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"})) + got, err := h.minter.Answer(ask) + if err == nil || len(got.Holders) != 0 { + t.Fatalf("a failed mint answered %+v, %v", got, err) + } + if strings.Contains(err.Error(), adminPassword) { + t.Fatal("the admin's password was said in an error") + } + // A wrong password is a refusal, said without it. + if err := os.WriteFile(h.minter.Forge.PasswordFile, []byte("wrong"), 0o600); err != nil { + t.Fatal(err) + } + h.forge.failMint = false + if _, err := h.minter.Answer(ask); err == nil || strings.Contains(err.Error(), "wrong") { + t.Fatalf("a refused login: %v", err) + } +} + +func TestTheAskIsReadStrictly(t *testing.T) { + h := newHarness(t) + for name, args := range map[string]map[string]any{ + "another provision": {"provision": "git", "seal_to": base64.StdEncoding.EncodeToString(h.public[:]), + "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}}, + "no key": {"provision": Provision, "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}}, + "short key": {"provision": Provision, "seal_to": "AAAA", "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}}, + "no holders": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:])}, + "a holder twice": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]), + "holders": []any{map[string]any{"node": "a", "module": "claude-code"}, map[string]any{"node": "a", "module": "claude-code"}}}, + } { + if _, err := readAsk(args); err == nil { + t.Errorf("%s was taken", name) + } + } +} + +func TestTheReadmeNamesWhatTheBundleServes(t *testing.T) { + var served []string + for _, tool := range Tools(MinterFromEnv) { + served = append(served, tool.Name) + } + if !slices.Equal(served, ToolNames) { + t.Fatalf("served %v, named %v", served, ToolNames) + } + readme, err := os.ReadFile("../../README.md") + if err != nil { + t.Fatal(err) + } + for _, n := range ToolNames { + if !strings.Contains(string(readme), "`"+n+"`") { + t.Errorf("the README does not name %s", n) + } + } + manifest, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(manifest), `"tool": "`+ToolName+`"`) || !strings.Contains(string(manifest), `"name": "`+Provision+`"`) { + t.Error("the manifest does not offer the provision with this tool as its issuer") + } +} + +// tokenIn is the token of account's credential lines, all one. +func tokenIn(t *testing.T, lines, account string) string { + t.Helper() + re := regexp.MustCompile(regexp.QuoteMeta(account) + `:([0-9a-f]{40})@`) + all := re.FindAllStringSubmatch(lines, -1) + if len(all) == 0 { + t.Fatalf("no credential for %s in the lines", account) + } + for _, m := range all { + if m[1] != all[0][1] { + t.Fatalf("%s's lines carry two tokens", account) + } + } + return all[0][1] +} diff --git a/modules/gitea/cmd/forge/account_test.go b/modules/gitea/cmd/forge/account_test.go new file mode 100644 index 00000000..d37c6594 --- /dev/null +++ b/modules/gitea/cmd/forge/account_test.go @@ -0,0 +1,120 @@ +package main + +// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module +// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge +// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the +// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement. + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea/internal/forgehttp" +) + +const toolsToken = "tools-token-never-shown" + +// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are +// refused as to a non-administrator, and the branches named protected say so as the forge does. +func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) { + dir := t.TempDir() + file := filepath.Join(dir, "token") + if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil { + t.Fatal(err) + } + var seen []string + const repo = "/api/v1/repos/acme/app" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.Path) + if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken { + http.Error(w, "unauthorized", 401) + return + } + if r.URL.Path == repo+"/branch_protections" { + http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403) + return + } + if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path { + if rule, ok := protected[name]; ok { + _, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`)) + return + } + } + r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above + f.handle(w, r) + })) + t.Cleanup(srv.Close) + return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen +} + +func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) { + f := newFake(t) + forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools") + got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365", + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err != nil { + t.Fatal(err) + } + if len(f.writes) != 1 || !got.Created { + t.Fatalf("%d writes, %+v", len(f.writes), got) + } +} + +func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) { + for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} { + f := newFake(t) + protected := map[string]string{} + if rule != "" { + protected[branch] = rule + } + forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools") + _, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch, + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") { + t.Errorf("%s: %v", branch, err) + } + if rule != "" && !strings.Contains(err.Error(), rule) { + t.Errorf("%s: the rule %s is not named: %v", branch, rule, err) + } + if len(f.writes)+len(f.patches) != 0 { + t.Errorf("%s: something was written", branch) + } + } +} + +func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) { + f := newFake(t) + // What the module kept before ADR 0365: the admin's token, its scopes line naming no account. + forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository") + _, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365", + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") { + t.Fatalf("%v", err) + } + if len(*seen) != 0 { + t.Fatalf("the forge was asked %v", *seen) + } +} + +func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) { + t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000") + t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token") + t.Setenv("MESH_GITEA_TOOLS_USER", "") + t.Setenv("MESH_GITEA_ADMIN_USER", "") + t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "") + f, err := ForgeFromEnv() + if err != nil { + t.Fatal(err) + } + if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" { + t.Fatalf("%+v", f.Client) + } +} diff --git a/modules/gitea/cmd/forge/commit.go b/modules/gitea/cmd/forge/commit.go index 6976da57..f3b5e0df 100644 --- a/modules/gitea/cmd/forge/commit.go +++ b/modules/gitea/cmd/forge/commit.go @@ -9,6 +9,8 @@ import ( "net/url" "strings" "unicode/utf8" + + "gitea/internal/forgehttp" ) // gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull @@ -33,6 +35,11 @@ type CommitAsk struct { Message string Files []FileChange Patch string // a unified diff, as git diff writes it, instead of Files + // PatchFile is the name of a file in the spool directory holding the patch (novox/hq issue 852), with the + // SHA-256 its caller says it has; commitAsk reads it into Patch. + PatchFile string + PatchSHA256 string + patchFileGiven bool } // Committed is what gitea_commit made. @@ -67,6 +74,11 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { a := CommitAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Branch: text(args, "branch"), Base: text(args, "base"), Message: strings.TrimSpace(raw(args, "message"))} a.Patch = raw(args, "patch") + if v, ok := args["patch_file"]; ok && v != nil { + a.patchFileGiven = true + a.PatchFile = raw(args, "patch_file") + } + a.PatchSHA256 = strings.ToLower(strings.TrimSpace(raw(args, "patch_sha256"))) if a.Branch == "" { return a, refuse("branch is required: the branch the commit goes on, a branch of its own (created from base when absent). Nothing was sent") } @@ -109,11 +121,23 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { a.Files = append(a.Files, fc) } } + sources := 0 + for _, g := range []bool{len(a.Files) > 0, strings.TrimSpace(a.Patch) != "", a.patchFileGiven} { + if g { + sources++ + } + } switch { - case len(a.Files) == 0 && strings.TrimSpace(a.Patch) == "": - return a, refuse("give files (each {path, content | content_base64 | delete: true}) or patch (a unified diff, as git diff writes it). Nothing was sent") - case len(a.Files) > 0 && strings.TrimSpace(a.Patch) != "": - return a, refuse("give files or patch, not both: one commit carries one of them. Nothing was sent") + case sources == 0: + return a, refuse("give files (each {path, content | content_base64 | delete: true}), patch (a unified diff, as git diff writes it) or patch_file (the name of a patch in the spool directory). Nothing was sent") + case sources > 1: + return a, refuse("give files, patch or patch_file, not more than one: one commit carries one of them. Nothing was sent") + case a.PatchSHA256 != "" && !a.patchFileGiven: + return a, refuse("patch_sha256 goes with patch_file only. Nothing was sent") + case a.patchFileGiven: + if err := checkPatchFileAsk(a); err != nil { + return a, err + } case len(a.Files) > maxFiles: return a, refuse("%d files is more than the %d one commit carries. Nothing was sent", len(a.Files), maxFiles) case len(a.Patch) > maxPatchBytes: @@ -132,13 +156,13 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { return a, refuse("%s is named twice in files. Nothing was sent", fc.Path) } seen[fc.Path] = true - given := 0 + have := 0 for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} { if g { - given++ + have++ } } - if given != 1 { + if have != 1 { return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path) } if fc.Content != nil && !utf8.ValidString(*fc.Content) { @@ -211,7 +235,32 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) { // Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed). var rules []Protection if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil { - return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err) + var status *forgehttp.StatusError + if !errors.As(err, &status) || status.Status != http.StatusForbidden { + return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err) + } + // The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365 + // §2). What it may read is the protection the forge applies to an existing branch: one protected is + // refused here. A branch not made yet is covered by no rule this account can see, and the forge itself + // judges the commit that makes it against every rule, refusing one a rule guards. + if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil { + return nil, err + } + b, err := f.branch(ctx, repo, a.Branch) + if err != nil { + return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err) + } + if b != nil && b.Protected { + rule := b.Rule + if rule == "" { + rule = a.Branch + } + rules = []Protection{{RuleName: rule}} + if !ruleCovers(rule, a.Branch) { + return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+ + "branch of its own. Nothing was sent", a.Branch, rule) + } + } } if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil { return nil, err diff --git a/modules/gitea/cmd/forge/forge.go b/modules/gitea/cmd/forge/forge.go index 3637116d..a28a915b 100644 --- a/modules/gitea/cmd/forge/forge.go +++ b/modules/gitea/cmd/forge/forge.go @@ -11,8 +11,8 @@ import ( "gitea/internal/forgehttp" ) -// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account -// (internal/forgehttp, shared with the npm-registry bundle beside it). +// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq +// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it). // Forge is the forge's HTTP interface. type Forge struct { @@ -91,12 +91,15 @@ func (p Protection) name() string { return p.BranchName } -// Branch is one branch with the commit it points at. +// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account +// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them. type Branch struct { Name string `json:"name"` Commit struct { ID string `json:"id"` } `json:"commit"` + Protected bool `json:"protected"` + Rule string `json:"effective_branch_protection_name"` } // branch is the named branch, nil when the forge has none of that name. diff --git a/modules/gitea/cmd/forge/forge_test.go b/modules/gitea/cmd/forge/forge_test.go index 7d9464df..1d315cc3 100644 --- a/modules/gitea/cmd/forge/forge_test.go +++ b/modules/gitea/cmd/forge/forge_test.go @@ -619,11 +619,17 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) { if len(a.Loads) != 1 || a.Loads[0] != a.Binary { t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary) } - for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + // The tools' own account, never the admin login (novox/hq ADR 0365 §2). + for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} { if a.Env[k] == "" { t.Errorf("the bundle is not given %s", k) } } + for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + if a.Env[k] != "" { + t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k) + } + } } } if !found { diff --git a/modules/gitea/cmd/forge/main.go b/modules/gitea/cmd/forge/main.go index ee5e1e0a..6388f096 100644 --- a/modules/gitea/cmd/forge/main.go +++ b/modules/gitea/cmd/forge/main.go @@ -55,7 +55,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { "the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " + "At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " + "the parent the forge made it on, read back from the branch, and says when the branch moved under " + - "it. Replaces git commit and git " + + "it. A patch too large to type goes as patch_file with patch_sha256: the name of a file in the " + + "spool directory (read once, applied as patch is, removed afterwards, never read from anywhere " + + "else). Replaces git commit and git " + "push of a feature branch. (a)", Input: map[string]any{ "type": "object", @@ -79,7 +81,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { "required": []string{"path"}, }, }, - "patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this or files"), + "patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this, patch_file or files"), + "patch_file": str("for a patch too large to type: the NAME (32 to 64 characters of 0-9a-f, chosen at random by the caller, no path) of a file the caller wrote into the forge patch spool directory (the module's forge-patches directory); needs patch_sha256; give this, patch or files"), + "patch_sha256": str("with patch_file: the file's SHA-256, in hex; the commit is refused, and nothing is sent, when the file's differs"), }, "required": []string{"owner", "repo", "branch", "message"}, }, @@ -88,7 +92,7 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { if err != nil { return nil, err } - return commit(ctx, f, a) + return commitAsk(ctx, f, os.Getenv(patchDirEnv), a) }), }, { diff --git a/modules/gitea/cmd/forge/patchfile.go b/modules/gitea/cmd/forge/patchfile.go new file mode 100644 index 00000000..ba040f91 --- /dev/null +++ b/modules/gitea/cmd/forge/patchfile.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" +) + +// A patch too large to type into a call is handed over as a file (novox/hq issue 852). The caller writes it into +// one spool directory the module declares (mode 0733: anyone writes, nobody lists) and names it in patch_file, +// with the file's SHA-256. The tool runs as the machine's account, so it reads nothing but that directory: the +// name is 32 to 64 characters of [0-9a-f], so it holds no separator and no dot; the file must be a regular file +// (never a symlink); and its hash must be the caller's, because without the sticky bit another writer could have +// replaced it. The file is removed once taken, whether the commit was made or not. + +const patchDirEnv = "MESH_GITEA_PATCH_DIR" + +var ( + patchFileName = regexp.MustCompile(`^[0-9a-f]{32,64}$`) + patchSHA = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +// checkPatchFileAsk judges the arguments of a patch_file ask, before any file is looked at. +func checkPatchFileAsk(a CommitAsk) error { + if !patchFileName.MatchString(a.PatchFile) { + return refuse("patch_file %q is not a name: 32 to 64 characters of 0-9a-f, no path (the file's name in the spool directory, chosen at random). Nothing was sent", a.PatchFile) + } + if !patchSHA.MatchString(a.PatchSHA256) { + return refuse("patch_file needs patch_sha256: the file's SHA-256, 64 hex characters. Nothing was sent") + } + return nil +} + +// commitAsk makes the commit the ask names; a patch_file is first read from the spool directory into the patch +// and removed afterwards. +func commitAsk(ctx context.Context, f *Forge, spool string, a CommitAsk) (*Committed, error) { + if !a.patchFileGiven { + return commit(ctx, f, a) + } + if err := checkPatchFileAsk(a); err != nil { + return nil, err + } + if spool == "" { + return nil, refuse("patch_file is not available: %s is not set, so there is no spool directory. Nothing was sent", patchDirEnv) + } + path := filepath.Join(spool, a.PatchFile) + defer func() { + // Whatever happened, the file is not left: removing a symlink removes the link, never its target. + if st, err := os.Lstat(path); err == nil && !st.IsDir() { + _ = os.Remove(path) + } + }() + body, err := readSpooled(path, a.PatchFile) + if err != nil { + return nil, err + } + got := sha256.Sum256(body) + if hex.EncodeToString(got[:]) != a.PatchSHA256 { + return nil, refuse("the file %s is not the patch the caller hashed: its SHA-256 is %s, patch_sha256 says %s. Nothing was sent", + a.PatchFile, hex.EncodeToString(got[:]), a.PatchSHA256) + } + a.Patch = string(body) + return commit(ctx, f, a) +} + +// readSpooled reads a regular file of at most maxPatchBytes, never through a symlink. +func readSpooled(path, name string) ([]byte, error) { + st, err := os.Lstat(path) + switch { + case errors.Is(err, os.ErrNotExist): + return nil, refuse("patch_file %s: no such file in the spool directory. Nothing was sent", name) + case err != nil: + return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err) + case !st.Mode().IsRegular(): + return nil, refuse("patch_file %s is not a regular file (a symlink or a directory is never followed). Nothing was sent", name) + case st.Size() > maxPatchBytes: + return nil, refuse("patch_file %s is %s, more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(int(st.Size())), mib(maxPatchBytes)) + } + fh, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, refuse("patch_file %s cannot be opened: %v. Nothing was sent", name, err) + } + defer fh.Close() + fst, err := fh.Stat() + if err != nil || !fst.Mode().IsRegular() { + return nil, refuse("patch_file %s is not a regular file. Nothing was sent", name) + } + // A hard link to a file elsewhere is no symlink, but it is not the caller's own file either. + if sys, ok := fst.Sys().(*syscall.Stat_t); !ok || sys.Nlink > 1 { + return nil, refuse("patch_file %s has more than one hard link: it must be a file of its own. Nothing was sent", name) + } + body, err := io.ReadAll(io.LimitReader(fh, maxPatchBytes+1)) + switch { + case err != nil: + return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err) + case len(body) > maxPatchBytes: + return nil, refuse("patch_file %s is more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(maxPatchBytes)) + case strings.TrimSpace(string(body)) == "": + return nil, refuse("patch_file %s is empty. Nothing was sent", name) + } + return body, nil +} diff --git a/modules/gitea/cmd/forge/patchfile_test.go b/modules/gitea/cmd/forge/patchfile_test.go new file mode 100644 index 00000000..cd0f34e1 --- /dev/null +++ b/modules/gitea/cmd/forge/patchfile_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +// novox/hq issue 852: a patch too large to type into a call is handed over as a file in one spool directory. + +const spoolName = "0123456789abcdef0123456789abcdef" +const spoolPatch = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-hello\n+hi\n" + +func sum(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) } + +func spoolWith(t *testing.T, name, content string) string { + t.Helper() + dir := t.TempDir() + if name != "" { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +func fileAsk(t *testing.T, name, sha string) CommitAsk { + t.Helper() + return ask(t, map[string]any{"branch": "fix/9", "patch_file": name, "patch_sha256": sha}) +} + +func TestAPatchFileIsAppliedAsThePatchWouldBeAndTheFileIsGone(t *testing.T) { + f := newFake(t) + dir := spoolWith(t, spoolName, spoolPatch) + got, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, spoolName, sum(spoolPatch))) + if err != nil { + t.Fatal(err) + } + if len(f.patches) != 1 || f.patches[0]["content"] != spoolPatch || f.patches[0]["new_branch"] != "fix/9" || got.Commit != "p1" { + t.Fatalf("%v %+v", f.patches, got) + } + if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) { + t.Fatalf("the file is still there: %v", err) + } +} + +func TestAPatchFileIsRemovedWhenTheForgeRefusesIt(t *testing.T) { + f := newFake(t) + dir := spoolWith(t, spoolName, spoolPatch) + // the default branch is refused after the file was read + a := ask(t, map[string]any{"branch": "main", "patch_file": spoolName, "patch_sha256": sum(spoolPatch)}) + if _, err := commitAsk(context.Background(), f.serve(), dir, a); err == nil { + t.Fatal("taken") + } + if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) { + t.Fatalf("the file is still there: %v", err) + } +} + +func TestAPatchFileThatCannotBeTakenIsRefusedBeforeAnythingIsSent(t *testing.T) { + outside := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(outside, []byte(spoolPatch), 0o600); err != nil { + t.Fatal(err) + } + type tc struct { + name string + setup func(dir string) + file string + sha string + want string + } + for _, c := range []tc{ + {"wrong sha", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), []byte(spoolPatch), 0o600) }, spoolName, sum("other"), "sha256"}, + {"not found", func(string) {}, spoolName, sum(spoolPatch), "no such file"}, + {"symlink", func(d string) { _ = os.Symlink(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "regular file"}, + {"hard link", func(d string) { _ = os.Link(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "hard link"}, + {"directory", func(d string) { _ = os.Mkdir(filepath.Join(d, spoolName), 0o700) }, spoolName, sum(spoolPatch), "regular file"}, + {"too large", func(d string) { + _ = os.WriteFile(filepath.Join(d, spoolName), []byte(strings.Repeat("x", maxPatchBytes+1)), 0o600) + }, spoolName, sum(spoolPatch), "MiB"}, + {"empty", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), nil, 0o600) }, spoolName, sum(""), "empty"}, + } { + f := newFake(t) + dir := t.TempDir() + c.setup(dir) + _, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, c.file, c.sha)) + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "Nothing was sent") { + t.Errorf("%s: %v", c.name, err) + } + if len(f.patches)+len(f.writes) != 0 { + t.Errorf("%s: something was sent", c.name) + } + } + if b, err := os.ReadFile(outside); err != nil || string(b) != spoolPatch { + t.Errorf("the file outside the spool was touched: %v", err) + } +} + +func TestAPatchFileAskThatCannotBeReadIsRefused(t *testing.T) { + ok := sum(spoolPatch) + for name, args := range map[string]map[string]any{ + "no sha": {"patch_file": spoolName}, + "sha not hex": {"patch_file": spoolName, "patch_sha256": strings.Repeat("z", 64)}, + "sha too short": {"patch_file": spoolName, "patch_sha256": ok[:63]}, + "empty name": {"patch_file": "", "patch_sha256": ok, "files": filesArg()}, + "dotdot": {"patch_file": "../" + spoolName, "patch_sha256": ok}, + "slash": {"patch_file": "a/" + spoolName, "patch_sha256": ok}, + "absolute": {"patch_file": "/etc/passwd", "patch_sha256": ok}, + "too short": {"patch_file": strings.Repeat("a", 31), "patch_sha256": ok}, + "too long": {"patch_file": strings.Repeat("a", 65), "patch_sha256": ok}, + "upper case": {"patch_file": strings.ToUpper(spoolName), "patch_sha256": ok}, + "not hex": {"patch_file": strings.Repeat("g", 32), "patch_sha256": ok}, + "with patch": {"patch_file": spoolName, "patch_sha256": ok, "patch": "diff"}, + "with files": {"patch_file": spoolName, "patch_sha256": ok, "files": filesArg(map[string]any{"path": "x", "content": "x"})}, + "sha without it": {"patch": "diff", "patch_sha256": ok}, + } { + full := map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b"} + for k, v := range args { + full[k] = v + } + _, err := readCommitAsk(full) + if err == nil || !strings.Contains(err.Error(), "Nothing was sent") { + t.Errorf("%s: %v", name, err) + } + } + // 64 characters is taken; so is an upper-case sha. + for _, n := range []string{spoolName, strings.Repeat("ab", 32)} { + if _, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b", + "patch_file": n, "patch_sha256": strings.ToUpper(sum(spoolPatch))}); err != nil { + t.Errorf("%s: %v", n, err) + } + } +} + +func TestAPatchFileWithoutASpoolDirectoryIsRefused(t *testing.T) { + f := newFake(t) + _, err := commitAsk(context.Background(), f.serve(), "", fileAsk(t, spoolName, sum(spoolPatch))) + if err == nil || !strings.Contains(err.Error(), "MESH_GITEA_PATCH_DIR") || !strings.Contains(err.Error(), "Nothing was sent") { + t.Fatalf("%v", err) + } +} + +func TestTheManifestDeclaresTheSpoolDirectoryAndGivesItToTheBundle(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []struct { + ID, Type, Path, Mode, Owner string + } `json:"resources"` + Build struct { + Artifacts []struct { + From string `json:"from"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` + } + if err := jsonInto(raw, &m); err != nil { + t.Fatal(err) + } + found := false + for _, r := range m.Resources { + if r.ID == "forge-patches" { + found = true + if r.Type != "directory" || r.Mode != "0733" || r.Owner != "${machine:account}" || r.Path == "" { + t.Errorf("%+v", r) + } + } + } + if !found { + t.Error("no forge-patches directory") + } + for _, a := range m.Build.Artifacts { + if a.From == "cmd/forge" && a.Env["MESH_GITEA_PATCH_DIR"] != "${dir:forge-patches}" { + t.Errorf("env %v", a.Env) + } + } +} diff --git a/modules/gitea/cmd/forge/templates_test.go b/modules/gitea/cmd/forge/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/gitea/cmd/forge/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/gitea/files/agent-teams.json b/modules/gitea/files/agent-teams.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/gitea/files/agent-teams.json @@ -0,0 +1 @@ +{} diff --git a/modules/gitea/files/config.json b/modules/gitea/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/gitea/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/gitea/files/origins b/modules/gitea/files/origins new file mode 100644 index 00000000..6bbfb357 --- /dev/null +++ b/modules/gitea/files/origins @@ -0,0 +1 @@ +https://${bound:route:name-web} diff --git a/modules/gitea/files/ready-authors b/modules/gitea/files/ready-authors new file mode 100644 index 00000000..794440bf --- /dev/null +++ b/modules/gitea/files/ready-authors @@ -0,0 +1,3 @@ +# The accounts whose "ready at " comment marks a pull request ready (novox/hq issue 722), from the setting +# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file. +authors=${config:ready-authors} diff --git a/modules/gitea/files/server.env b/modules/gitea/files/server.env new file mode 100644 index 00000000..8cb88e22 --- /dev/null +++ b/modules/gitea/files/server.env @@ -0,0 +1,7 @@ +GITEA__security__INTERNAL_TOKEN=${secret:internal-token} +GITEA__database__DB_TYPE=postgres +GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +GITEA__database__NAME=${bound:postgres-database:as} +GITEA__database__USER=${bound:postgres-database:as} +GITEA__database__PASSWD=${secret:postgres-database} +GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1 diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index c05b9bd6..ea8089d6 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -1,5 +1,10 @@ module gitea -go 1.22 +go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.7 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 + +require ( + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 // indirect +) diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index b474419e..afe914a8 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,2 +1,6 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index d64045a7..4c444060 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -40,10 +40,12 @@ import { emit, on } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { commitStatusTool } from "./delivery.js"; import { mergeTool } from "./merge.js"; -import { GiteaClient, movedSince } from "./client.js"; +import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, - type RepoCheckFacts, reposToLook } from "./pulls.js"; + repoCheckFacts, reposToLook } from "./pulls.js"; import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; +import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; +import { keepSiblings, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is @@ -135,6 +137,7 @@ async function pollMerged(client: GiteaClient): Promise { await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title, head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url }); console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`); + await siblingMoves(client, repo.full_name, pull.number); } announced.add(closedKey); changed = true; @@ -145,57 +148,9 @@ async function pollMerged(client: GiteaClient): Promise { // at once. const fresh = !!pull.merged_at && !!since && pull.merged_at > since; if (primedMerges && fresh) { - // What it changed, asked for only now: a module is rebuilt because a file inside its own - // directory moved, and without this every module built from a repository is rebuilt for a - // change to any of them (novox/hq 04-ISSUES/131). - const changed = await client.listPullFiles(repo.owner, repo.name, pull.number); - // Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a - // change inside one is that module's, held or not, and only a file in none is shared code. Not - // said when the list is cut or the forge could not be asked; the mesh then keeps its old rule. - let moduleDirs: string[] | null = null; - if (!changed.truncated) { - try { - moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths); - } catch (err) { - console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` + - `so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`); - } - } - // The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made - // from the forge's word when its owner never heard the head. - let headChecks: Record | null = null; - if (pull.head_sha) { - try { - headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha); - } catch (err) { - console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`); - } - } - await emit("pull.merged", { - owner: repo.owner, - repo: repo.name, - number: pull.number, - title: pull.title, - body: pull.body, - head_sha: pull.head_sha, - ...(headChecks ? { head_checks: headChecks } : {}), - head: pull.head, - base: pull.base, - merge_commit_sha: pull.merge_commit_sha, - merged_at: pull.merged_at, - clone_url: repo.clone_url, - html_url: pull.html_url, - paths: changed.paths, - paths_truncated: changed.truncated, - // Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten, - // not built. - removed: changed.removed, - ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), - }); - // Said, because a trigger that fires silently is indistinguishable from one that did not - // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. - console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`); + if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`); } + else announced.add(pull.merge_commit_sha); announced.add(pull.merge_commit_sha); changed = true; } @@ -206,6 +161,75 @@ async function pollMerged(client: GiteaClient): Promise { lastLook = began; } +type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string }; +type PullOf = Awaited>[number]; + +/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ +async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise { + if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false; + // Recorded before the first wait, so the other path finds it taken while this one is still asking the forge. + announced.add(pull.merge_commit_sha); + try { + // What it changed, asked for only now: a module is rebuilt because a file inside its own + // directory moved, and without this every module built from a repository is rebuilt for a + // change to any of them (novox/hq 04-ISSUES/131). + const changed = await client.listPullFiles(repo.owner, repo.name, pull.number); + // Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a + // change inside one is that module's, held or not, and only a file in none is shared code. Not + // said when the list is cut or the forge could not be asked; the mesh then keeps its old rule. + let moduleDirs: string[] | null = null; + if (!changed.truncated) { + try { + moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths); + } catch (err) { + console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` + + `so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`); + } + } + // The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made + // from the forge's word when its owner never heard the head. + let headChecks: Record | null = null; + if (pull.head_sha) { + try { + headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha); + } catch (err) { + console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`); + } + } + await emit("pull.merged", { + owner: repo.owner, + repo: repo.name, + number: pull.number, + title: pull.title, + body: pull.body, + head_sha: pull.head_sha, + ...(headChecks ? { head_checks: headChecks } : {}), + head: pull.head, + base: pull.base, + merge_commit_sha: pull.merge_commit_sha, + merged_at: pull.merged_at, + clone_url: repo.clone_url, + html_url: pull.html_url, + paths: changed.paths, + paths_truncated: changed.truncated, + // Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten, + // not built. + removed: changed.removed, + ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), + }); + // Said, because a trigger that fires silently is indistinguishable from one that did not + // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. + console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`); + // Its own merge of the head that was read is no sibling's move; any other is (ADR 0382 §5). + await siblingMoves(client, repo.full_name, pull.number); + } catch (err) { + announced.delete(pull.merge_commit_sha); + throw err; + } + keepAnnounced(); + return true; +} + // **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it // against every machine of its facts before it merges. Kept beside the merges' record, so a restart // announces nothing twice; the first look on a machine with no record announces only what moved in the @@ -228,6 +252,33 @@ function keepHeads(): void { writeFileSync(tmp, JSON.stringify({ heads })); renameSync(tmp, pullsRecord); } +/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ +async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise { + const key = `${repo.full_name}#${pull.number}`; + const sha = String(pull.head_sha ?? ""); + const before = heads[key]; + // Claimed before the first wait, so the other path finds it taken while this one is still announcing. + if (!claimHead(heads, key, sha)) return false; + try { + // The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5). + await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull)); + } catch (err) { + if (before === undefined) delete heads[key]; + else heads[key] = before; + throw err; + } + keepHeads(); + // A sibling's new head puts out of date every verdict that read the sibling at another head (ADR 0382 §5). + await siblingMoves(client, repo.full_name, pull.number); + // Said, so an operator knows the mesh was asked to check it. + console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`); + // Pending until the verdict comes, so the pull request says a check is running rather than nothing — and + // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). + await gates.pending(repo.owner, repo.name, sha); + return true; +} +type OpenOf = Awaited>[number]; + let lastPullLook = ""; // **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a // heuristic, and a heuristic that misses is caught by the next sweep rather than never. @@ -259,15 +310,8 @@ async function pollPulls(client: GiteaClient): Promise { const key = `${repo.full_name}#${pull.number}`; const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo); if (fresh) { - // The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5). - await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull)); - // Said, so an operator knows the mesh was asked to check it. - console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`); - // Pending until the verdict comes, so the pull request says a check is running rather than nothing — and - // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). - await gates.pending(repo.owner, repo.name, String(pull.head_sha)); - } - heads[key] = String(pull.head_sha); + if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`); + } else heads[key] = String(pull.head_sha); changed = true; } if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true; @@ -303,7 +347,22 @@ function keepTrunks(): void { /** Whether a trunk of one repository moved, weighed with this module's forge and its one writer (trunk.ts). */ function trunksMoved(client: GiteaClient, owner: string, name: string): Promise { - return weighTrunks(client, gates, trunks, owner, name); + return weighTrunks(client, gates, trunks, owner, name, () => siblingMoves(client, `${owner}/${name}`)); +} + +// **The siblings each open pull request's latest verdict names** (novox/hq ADR 0382 §5, siblings.ts), by member, kept beside +// the other records. A sibling's new head, close unmerged, merge, or a move of its base puts the merge check of every +// member whose verdict names it back to pending, with the reason; nothing is run for it. +const siblingsRecord = siblingsFile(process.env.MESH_GITEA_STATE_DIR); +const siblingNamed = readSiblings(siblingsRecord); + +/** A sibling moved, weighed (ADR 0382 §5): never fatal to what heard it. */ +async function siblingMoves(client: GiteaClient, repository: string, number?: number): Promise { + try { + if (await weighSiblings(client, gates, siblingNamed, repository, number)) keepSiblings(siblingsRecord, siblingNamed); + } catch (err) { + console.error(`[gitea] whether a sibling of ${repository}${number ? "#" + number : ""} moved could not be weighed — ${err instanceof Error ? err.message : err}`); + } } // **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one @@ -325,6 +384,17 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored"); return; } + // The siblings this verdict read are kept for the member (ADR 0382 §5); a verdict that read none removes the record. + const siblings = siblingsOf(c.siblings); + if (c.number && noteVerdict(siblingNamed, `${c.owner}/${c.repo}#${c.number}`.toLowerCase(), c.commit, siblings)) { + keepSiblings(siblingsRecord, siblingNamed); + } + // A sibling that moved while the check ran is weighed once this verdict has landed, in the head's own turn after it. + if (siblings.length) { + for (const repository of new Set(siblings.map((s) => s.repository))) { + void Promise.resolve().then(() => siblingMoves(client, repository)); + } + } // Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239). let target: string | undefined; let base: string | undefined; @@ -362,22 +432,6 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); } -// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the -// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection -// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads -// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is -// required would let an untested repository merge. -async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise { - if (c["repo-check"]?.verdict !== "warning") return {}; - const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); - if (defined !== false) return { defined }; - const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined); - if (!rules) return { defined }; - const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main")); - const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); - return { defined, required }; -} - // **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, // where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. @@ -391,10 +445,16 @@ const gates = new GateWriter({ // **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own // process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to // reach, nothing is served, as the tools' bundle serves nothing then. -registerModuleTools("gitea", () => (gitea ? [commitStatusTool({ +/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what + * gitea_events says of them (webhook.ts). */ +const pace = new Pace(); +if (!gitea) pace.unheard("the forge is not watched: no way to a token", false); + +// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). +registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gates, -}), mergeTool(gitea, { gates })] : [])); +}), mergeTool(gitea, { gates })] : [])]); // **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when // the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. @@ -413,12 +473,70 @@ async function buildStarted(event: { body: unknown }): Promise { } } +/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */ +async function actOn(client: GiteaClient, event: ForgeEvent): Promise { + const b = event.body as { owner?: string; repo?: string; number?: number }; + if (!b.owner || !b.repo || !b.number) return; + if (event.name === "forge.pull-request.closed") { + // Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5). + if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number); + return; + } + const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name); + const merged = event.name === "forge.pull-request.merged"; + if (!head && !merged) return; + const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]); + if (head && pull.state === "open") await announceHead(client, repo, pull); + if (merged) await announceMerge(client, repo, pull); +} + +/** + * **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside + * the token; the webhook is made or corrected with the admin account (ADR 0266: the tools' token holds no admin scope). + * Answers whether events are heard; a forge that refuses is said, and the polls keep their pace. + */ +async function listenForForge(client: GiteaClient): Promise { + const stateDir = process.env.MESH_GITEA_STATE_DIR; + const url = process.env.MESH_GITEA_HOOK_URL; + if (!stateDir || !url) { + console.log("[gitea] no webhook receiver: MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset, so the polls alone watch the forge"); + pace.unheard("MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset", false); + return false; + } + try { + const secret = keptSecret(join(stateDir, "webhook.secret")); + const port = Number(process.env.MESH_GITEA_HOOK_PORT ?? HOOK_PORT); + const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0"; + const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery), + refused: (reason, from) => pace.refused(reason, from), + publish: (event) => emit(event.name, event.body), + act: (event) => actOn(client, event) }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => resolve()); + }); + const hook = await ensureSystemHook(GiteaAdmin.fromEnv(), url.replace(/\/+$/, "") + HOOK_PATH, secret); + pace.listening(host, port, hook.url, hook); + console.log(`[gitea] listening for the forge's events on ${host}:${port}; its system webhook ${hook.id} ${hook.did} ` + + `at ${hook.url} (in place of ${hook.removed.system} system, ${hook.removed.default} default and ` + + `${hook.removed.repository} repository webhook(s)${hook.removed.repositories.length ? ` in ${hook.removed.repositories.join(", ")}` : ""}), ` + + `${hook.events.length} event families, signed`); + return true; + } catch (err) { + const why = err instanceof Error ? err.message : String(err); + pace.unheard(why); + console.error(`[gitea] the forge's events are not heard, so the polls keep their pace — ${why}`); + return false; + } +} + if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up // yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line. let failing: string | null = null; - const tick = (fn: () => Promise, everyMs: number): void => { + const tick = (fn: () => Promise, every: number | (() => number)): void => { + const everyMs = (): number => (typeof every === "number" ? every : every()); // **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a // pass that outlasts its interval delays the next instead of running beside it — two passes at once // could each announce the same merge before either recorded it. @@ -433,12 +551,15 @@ if (gitea) { if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); failing = why; }) - .finally(() => setTimeout(run, everyMs)); + .finally(() => setTimeout(run, everyMs())); run(); }; + // **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the + // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. + await listenForForge(client); tick(() => pollRepos(client), 60_000); - tick(() => pollMerged(client), 30_000); - tick(() => pollPulls(client), 30_000); + tick(() => pollMerged(client), () => pace.every()); + tick(() => pollPulls(client), () => pace.every()); await on("mesh-controller.checked", (event) => { const c = (event.body ?? {}) as Checked; // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). diff --git a/modules/gitea/internal/forgehttp/forgehttp.go b/modules/gitea/internal/forgehttp/forgehttp.go index 9b76e553..3b3e8c89 100644 --- a/modules/gitea/internal/forgehttp/forgehttp.go +++ b/modules/gitea/internal/forgehttp/forgehttp.go @@ -1,7 +1,17 @@ // Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge, -// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered -// into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call -// is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539). +// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539). +// +// Two ways in, by what the manifest hands the bundle: +// +// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module +// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A +// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's — +// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints +// nothing; a token the forge rejects is renewed by the module's next call, and said here. +// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR +// 0086), by basic authentication, when no token file is named. +// +// A credential is read when a call is made and never logged, answered or put in an error. package forgehttp import ( @@ -25,12 +35,31 @@ type Client struct { URL string // e.g. http://127.0.0.1:3000 User string PasswordFile string - HTTP *http.Client // when nil, a client with Timeout - Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero + // TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login; + // Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools). + TokenFile string + Account string + HTTP *http.Client // when nil, a client with Timeout + Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero } -// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. +// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts). +const ToolsAccount = "mesh-tools" + +// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle +// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login. func FromEnv() (Client, []string) { + if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" { + c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file, + Account: os.Getenv("MESH_GITEA_TOOLS_USER")} + if c.Account == "" { + c.Account = ToolsAccount + } + if c.URL == "" { + return c, []string{"MESH_GITEA_URL"} + } + return c, nil + } c := Client{ URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), User: os.Getenv("MESH_GITEA_ADMIN_USER"), @@ -80,14 +109,45 @@ func (c *Client) password() (string, error) { return strings.TrimSpace(string(raw)), nil } +// token is the tools' account's token, refused unless its scopes line names that account. +func (c *Client) token() (string, error) { + raw, err := os.ReadFile(c.TokenFile) + if err != nil { + return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err) + } + line, err := os.ReadFile(c.TokenFile + ".scopes") + if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) { + return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile) + } + return token, nil +} + +// authorize puts the bundle's credential on a request: the tools' token, or the admin login. +func (c *Client) authorize(req *http.Request) error { + if c.TokenFile != "" { + token, err := c.token() + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+token) + return nil + } + password, err := c.password() + if err != nil { + return err + } + req.SetBasicAuth(c.User, password) + return nil +} + // Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit; // 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it // exactly: a cut body is refused, never read as if it were whole. func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) { - password, err := c.password() - if err != nil { - return nil, err - } var body io.Reader if payload != nil { raw, err := json.Marshal(payload) @@ -100,7 +160,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit if err != nil { return nil, err } - req.SetBasicAuth(c.User, password) + if err := c.authorize(req); err != nil { + return nil, err + } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") @@ -118,6 +180,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20) } switch { + case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "": + return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w", + method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"}) case res.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound) case res.StatusCode >= 300: diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index 05ee77a2..fc70b6ef 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -11,7 +11,8 @@ import type { CommitStatus, GiteaClient } from "./client.js"; import { mergeableUnder, newestByContext } from "./checks.js"; import { MERGE_METHODS, Refused, checkName } from "./guard.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js"; -import { announcementOf, askedWords, outOfDateWords, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js"; +import { groupRefusal, memberKey, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; +import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js"; /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; @@ -54,9 +55,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition const method = args.method ? String(args.method) : "merge"; if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); - // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left - // to the forge, whose administrator — the account this tool's token is minted for — may merge past a - // rule that lets it. A rule that cannot be read is no rule passed. + // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the + // forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge + // applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that + // cannot be read is no rule passed. // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the // head was and nothing told it which head the review had approved. The caller names it; a head that moved @@ -80,7 +82,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); } - const rule = await gitea.branchProtection(owner, repo, pull.base); + const rule = await gitea.appliedProtection(owner, repo, pull.base); // Only into a branch whose protection requires the checks: a pull request whose base was moved to a // branch nothing guards would merge past every check (novox/hq ADR 0266). if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { @@ -96,7 +98,11 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition const checks = await gitea.headChecks(owner, repo, pull.head_sha); const at = verdictAtTrunk(checks, trunkHead); if (!at.current) { - const judged = at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against"; + // A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a + // trunk's move is, and a failed ask puts those words back, not the trunk's. + const siblingWords = siblingOutOfDate(checks[CHECK_CONTEXT]?.description) ? checks[CHECK_CONTEXT]?.description : undefined; + const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})` + : at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against"; const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`); const head = twelve(pull.head_sha), now = twelve(trunkHead); if (at.onItsWay) { @@ -122,7 +128,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition const cloneURL = (await gitea.getRepo(owner, repo)).clone_url; await deps.emit("pull.updated", await announcementOf(gitea, owner, repo, cloneURL, pull)); } catch (err) { - await say(outOfDateWords(at.judged, trunkHead)).catch(() => {}); + await say(siblingWords ?? outOfDateWords(at.judged, trunkHead)).catch(() => {}); throw new Error(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + `${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`); } @@ -138,6 +144,25 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` + `its checks succeed; no tool merges past them (novox/hq ADR 0266)`); } + // **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the + // siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record + // of them is no verdict to merge on, never read as none. + const heard = readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)]; + const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : []; + if (!siblings.length && namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) { + throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` + + `here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`); + } + if (siblings.length) { + const missing = await groupRefusal({ headChecks: (o, r, sha) => gitea.headChecks(o, r, sha), + approvedAt: (o, r, n, h) => gitea.approvedAt(o, r, n, h) }, + { owner, repo, number, head: String(pull.head_sha) }, siblings); + if (missing) { + throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict was judged beside ${siblings.length} ` + + `sibling(s), and ${missing}. A member judged beside its siblings merges once its group passed at the heads that ` + + `verdict read and each of them is approved there (novox/hq ADR 0382). Nothing was merged`); + } + } await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha); // The merge commit only exists now; answered so the caller can follow what is built from it. // pull.merged is the events entrypoint's to announce (index.ts), once, within its poll. diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 5f751f90..a086f1d3 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -1,6 +1,7 @@ { "module": "gitea", "version": "1", + "moved": true, "requires": [ "postgres-database", "route", @@ -42,7 +43,47 @@ "issue.opened", "pull.merged", "pull.updated", - "pull.closed" + "pull.closed", + "forge.push", + "forge.create", + "forge.delete", + "forge.fork", + "forge.status", + "forge.pull-request.opened", + "forge.pull-request.reopened", + "forge.pull-request.synchronized", + "forge.pull-request.edited", + "forge.pull-request.closed", + "forge.pull-request.merged", + "forge.pull-request.labeled", + "forge.pull-request.unlabeled", + "forge.pull-request.assigned", + "forge.pull-request.unassigned", + "forge.pull-request.reviewed", + "forge.pull-request.review-requested", + "forge.pull-request.commented", + "forge.pull-request.changed", + "forge.issue-comment.created", + "forge.issue-comment.edited", + "forge.issue-comment.deleted", + "forge.issues.opened", + "forge.issues.reopened", + "forge.issues.closed", + "forge.issues.edited", + "forge.issues.labeled", + "forge.issues.unlabeled", + "forge.issues.assigned", + "forge.issues.unassigned", + "forge.issues.changed", + "forge.release.published", + "forge.release.updated", + "forge.release.deleted", + "forge.release.changed", + "forge.repository.created", + "forge.repository.deleted", + "forge.repository.changed", + "forge.wiki.changed", + "forge.other" ], "consumes": [ "mesh-controller.checked", @@ -61,7 +102,14 @@ "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take" + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" + }, + { + "name": "forge-events", + "port": 3047, + "protocol": "tcp", + "from": "machine", + "why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)" } ], "serves": { @@ -109,6 +157,26 @@ } } }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.", + "trusted": true, + "default": "" + }, + "agent-read-teams": { + "type": "object", + "description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"\": {\"repos\": [\"/\", ...], \"members\": [\"\", ...]}}. The forge gets a team agent-read- that reads only, with exactly those repositories and the accounts agent-; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.", + "trusted": true, + "default": {} + }, + "ready-authors": { + "type": "string", + "description": "the forge accounts whose comment \"ready at \" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word", + "trusted": true, + "default": "mesh-tools mesh-admin jschoubben" + } + }, "resources": [ { "id": "mesh-state", @@ -122,6 +190,21 @@ "path": "${dir:mesh-state}/state", "mode": "0700" }, + { + "id": "ready-authors", + "type": "file", + "path": "${dir:mesh-state}/ready-authors", + "mode": "0600", + "trusted": true, + "template": "files/ready-authors" + }, + { + "id": "forge-patches", + "type": "directory", + "path": "/var/lib/mesh/forge-patches", + "mode": "0733", + "owner": "${machine:account}" + }, { "id": "state", "type": "directory", @@ -138,7 +221,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n" + "template": "files/server.env" }, { "id": "data", @@ -149,6 +232,13 @@ { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/api/healthz", + "status": 200, + "needs": "postgres-database" + }, "name": "gitea", "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", "env": { @@ -194,13 +284,53 @@ ], "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" }, + { + "id": "origins", + "type": "file", + "path": "${dir:mesh-state}/origins", + "mode": "0644", + "template": "files/origins" + }, { "id": "runtime-config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url" + ] + }, + { + "id": "agent-teams-file", + "type": "file", + "path": "${dir:mesh-state}/agent-teams.json", + "mode": "0600", + "template": "files/agent-teams.json", + "merge": "json", + "takes-config": [ + "agent-read-teams" + ] + }, + { + "id": "agent-teams", + "type": "process", + "name": "gitea-agent-teams", + "artifact": "agent-teams", + "run": [ + "./agent-teams" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_AGENT_TEAMS_FILE": "${dir:mesh-state}/agent-teams.json" + }, + "restart-on": [ + "agent-teams-file" + ] } ], "provides": [ @@ -215,6 +345,14 @@ { "name": "git", "scope": "mesh" + }, + { + "name": "gitea-read", + "scope": "mesh", + "credential": { + "issued-by": "software", + "tool": "gitea_agent_token" + } } ], "build": { @@ -238,8 +376,13 @@ "MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", - "MESH_RECEIVES": "${dir:grants}/npm.json" + "MESH_RECEIVES": "${dir:grants}/npm.json", + "MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors", + "MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047", + "MESH_GITEA_HOOK_PORT": "3047", + "MESH_GITEA_HOOK_HOST": "172.17.0.1" } }, { @@ -271,9 +414,43 @@ ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", - "MESH_GITEA_ADMIN_USER": "mesh-admin", - "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token", + "MESH_GITEA_PATCH_DIR": "${dir:forge-patches}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "agent-token", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-token", + "binary": "agent-token", + "loads": [ + "agent-token" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins" + } + }, + { + "name": "agent-teams", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-teams", + "binary": "agent-teams", + "loads": [ + "agent-teams" + ] } ] }, diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index a51156a1..c6c92667 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -9,8 +9,9 @@ // // Pure functions here, so they are tested without a forge; index.ts does the asking and the setting. -import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js"; +import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js"; import { movedSince } from "./client.js"; +import { UNKNOWN_MARK, besideWords, siblingsOf } from "./siblings.js"; /** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */ export const CHECK_CONTEXT = "mesh/merge-gate"; @@ -65,6 +66,13 @@ export interface Checked { trunk?: string; beside?: Record; took?: string; + /** The other members of its delivery group the check read beside the head, each at its head merged onto its base + * commit (novox/hq ADR 0382 §4); absent when it read none, and from a controller from before the rule. */ + siblings?: { repository: string; number: number; head: string; base: string; base_commit: string }[]; + /** Why the siblings could not be learned (mesh-delivery did not answer), when that is so. */ + siblings_unknown?: string; + /** Where the judge came from, when it came from the group (ADR 0382 §3): said in the account. */ + judge_from?: string; } /** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */ @@ -163,7 +171,7 @@ function described(verdict: string, summary: string, modules?: string[], depende return clipped(d); } -function clipped(d: string): string { +export function clipped(d: string): string { d = d.replace(/\s+/g, " ").trim(); return d.length > 140 ? d.slice(0, 139) + "…" : d; } @@ -176,6 +184,24 @@ export interface RepoCheckFacts { required?: boolean; } +// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the +// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection +// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads +// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is +// required would let an untested repository merge. The base's protection is the one the forge applies, read +// from the branch: the rules' own list is a repository administrator's, which the tools' account is not +// (novox/hq ADR 0365). +export async function repoCheckFacts( + forge: Pick, c: Checked, base: string | undefined): Promise { + if (c["repo-check"]?.verdict !== "warning") return {}; + const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); + if (defined !== false) return { defined }; + const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined); + if (rule === undefined) return { defined }; + const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); + return { defined, required }; +} + /** The description of a required repository check that is not defined. */ export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " + "a person adds one, or lifts the requirement from the branch's protection"; @@ -220,8 +246,12 @@ export function statusesFor(c: Checked, facts: RepoCheckFacts = {}): CommitStatu * 7a8b9c0d1e2f — pass: …", so the forge's 140 characters never clip it away; the trunk's move is weighed by it. A * verdict that names no trunk commit is said as before. */ function judgedFirst(c: Checked, status: CommitStatus): CommitStatus { - if (!c.trunk) return status; - return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)} — ${status.description}`) }; + // The siblings it read, named after the commits (ADR 0382 §4), so the clip never takes them either. + const siblings = siblingsOf(c.siblings); + // A verdict that could not learn them says so on the status too (clause 1: it is still checked, and merges). + const mark = siblings.length ? besideWords(siblings) : c.siblings_unknown ? UNKNOWN_MARK : ""; + if (!c.trunk) return mark ? { ...status, description: clipped(`${mark} — ${status.description}`) } : status; + return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)}${mark ? " " + mark : ""} — ${status.description}`) }; } /** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a @@ -240,6 +270,14 @@ export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | nul lines.splice(1, 0, `Judged: head \`${c.commit.slice(0, 12)}\` merged into trunk \`${c.trunk.slice(0, 12)}\`` + (beside ? `; read beside it: ${beside}` : "")); } + const siblings = siblingsOf(c.siblings); + if (siblings.length) { + lines.splice(c.trunk ? 2 : 1, 0, `Siblings read beside it: ` + siblings.map((s) => + `${s.repository}#${s.number} \`${s.head.slice(0, 12)}\` merged onto \`${s.base_commit.slice(0, 12)}\` (${s.base})`).join(", ") + + (c.judge_from ? `; judged by ${c.judge_from}` : "")); + } else if (c.siblings_unknown) { + lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`); + } lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` + (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` + (gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : "")); diff --git a/modules/gitea/siblings.ts b/modules/gitea/siblings.ts new file mode 100644 index 00000000..64d05aad --- /dev/null +++ b/modules/gitea/siblings.ts @@ -0,0 +1,286 @@ +// **A delivery group's member is checked beside the other members' heads, and a sibling's move puts that verdict out of +// date** (novox/hq ADR 0382 §5, §6; issue 867). +// +// The controller's verdict (`checked`) names the siblings its check read: each as the sibling's repository, pull +// request, head, base branch and the base commit the sibling's head was merged onto. This module, the forge's holder, +// keeps them per open pull request, beside its other records, and: +// +// - on its own hearing of a sibling's new head, of its close unmerged, or of a move of its base, sets +// mesh/merge-gate and mesh/repo-check of every member whose verdict names that sibling back to pending, with the +// reason. Nothing is run for it. The one move that does not count is the sibling's own merge of the head that was +// read onto the base commit that was read (in either order of the two merges); +// - lets the merge tool refuse a verdict that read siblings unless mesh/delivery-group passed at the very heads it +// read, and every sibling head it read carries an approving review at that head (merge.ts). +// +// A verdict without siblings (a controller from before ADR 0382) is none of this: nothing is kept and nothing changes. +// Pure functions and one small file of the module's own state, so this is tested without a forge. + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { CommitStatus, GiteaPull, HeadCheck } from "./client.js"; +import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, clipped } from "./pulls.js"; +import { sameCommit, twelve, type OutOfDateWriter } from "./trunk.js"; + +/** The status the delivery group's owner sets on each member's head (novox/hq ADR 0239). */ +export const GROUP_CONTEXT = "mesh/delivery-group"; + +/** One sibling a verdict read, as the controller names it (contract of ADR 0382 §4). */ +export interface SiblingNamed { + repository: string; + number: number; + head: string; + base: string; + base_commit: string; + /** Set here, never by the controller: the sibling merged its head onto its base commit read, so it is no sibling's + * move any more. */ + merged?: boolean; +} + +/** What is kept of one member's latest verdict that read siblings: the head it was of and the siblings it named. */ +export interface SiblingEntry { + head: string; + siblings: SiblingNamed[]; +} + +/** The records, by `owner/repo#number` of the member. */ +export type SiblingRecord = Record; + +/** The member's key. */ +export function memberKey(owner: string, repo: string, number: number): string { + return `${owner}/${repo}#${number}`.toLowerCase(); +} + +/** The siblings a verdict names, trimmed to what is kept; none when it names none or names one badly. */ +export function siblingsOf(named: unknown): SiblingNamed[] { + if (!Array.isArray(named)) return []; + const out: SiblingNamed[] = []; + for (const s of named) { + const r = s as Record; + const repository = String(r?.repository ?? ""), head = String(r?.head ?? ""), base = String(r?.base ?? ""); + const number = Number(r?.number); + if (!repository || !head || !base || !Number.isInteger(number) || number <= 0) continue; + out.push({ repository, number, head, base, base_commit: String(r?.base_commit ?? "") }); + } + return out; +} + +/** The siblings as a status names them: "mesh-host#114@abcdef123456, …". */ +export function siblingWords(siblings: SiblingNamed[]): string { + return siblings.map((s) => `${s.repository.split("/").pop()}#${s.number}@${twelve(s.head)}`).join(", "); +} + +/** What a status of a verdict that read siblings carries, after the commits it judged. */ +export function besideWords(siblings: SiblingNamed[]): string { + // Short, so that the verdict word and the start of its reason survive the forge's 140 characters: the first sibling + // by its pull request and 8 characters of its head, and how many more. The whole list is the holder's record and the comment's. + const first = siblings[0]; + const more = siblings.length > 1 ? ` +${siblings.length - 1} more` : ""; + return `beside ${first.repository.split("/").pop()}#${first.number}@${twelve(first.head).slice(0, 8)}${more}`; +} + +/** The mark a status carries when the siblings could not be learned (ADR 0382 §1): said, never silent. */ +export const UNKNOWN_MARK = "siblings unknown"; + +/** Whether a status names that its verdict read siblings (so that a record lost does not read as none). */ +export function namesSiblings(description: string | undefined): boolean { + return / beside [\w.-]+#\d+@[0-9a-f]{8}\b/.test(description ?? ""); +} + +/** Whether a status is a verdict put out of date by a sibling's move. */ +export function siblingOutOfDate(description: string | undefined): boolean { + return /^out of date: judged beside /.test(description ?? ""); +} + +// --- the record on disk ---------------------------------------------------------------------------------------- + +/** The file the holder keeps the siblings its verdicts name in, beside its other records. */ +export function siblingsFile(stateDir: string | undefined): string | null { + return stateDir ? `${stateDir.replace(/\/+$/, "")}/siblings-named.json` : null; +} + +export function readSiblings(file: string | null): SiblingRecord { + if (!file || !existsSync(file)) return {}; + try { + return (JSON.parse(readFileSync(file, "utf8")) as { members?: SiblingRecord }).members ?? {}; + } catch { + return {}; + } +} + +export function keepSiblings(file: string | null, record: SiblingRecord): void { + if (!file) return; + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file + ".tmp", JSON.stringify({ members: record })); + renameSync(file + ".tmp", file); + } catch (err) { + console.error(`[gitea] the siblings a verdict named could not be kept — ${err instanceof Error ? err.message : err}`); + } +} + +/** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */ +export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean { + if (siblings.length === 0) { + if (!(key in record)) return false; + delete record[key]; + return true; + } + record[key] = { head, siblings }; + return true; +} + +// --- what a sibling's move is --------------------------------------------------------------------------------- + +/** What weighing a sibling reads of the forge. */ +export interface SiblingForge { + getPullRequest(owner: string, repo: string, index: number): Promise; + branchHead(owner: string, repo: string, branch: string): Promise; + commitParents(owner: string, repo: string, sha: string): Promise; +} + +/** The reason a verdict that named this sibling is out of date, from what the forge holds now, or nothing; and whether + * the sibling has merged its head read onto the base commit read, which ends it as a sibling. */ +export async function siblingMoved(forge: SiblingForge, s: SiblingNamed): Promise<{ reason?: string; merged?: boolean }> { + const [owner, repo] = s.repository.split("/"); + const who = `${repo}#${s.number}`; + const pull = await forge.getPullRequest(owner, repo, s.number); + if (pull.merged) { + // Its own merge of the head that was read onto the base commit that was read: the head the merge joined is the head + // read, and the commit it was made onto is the base commit read. Any other merge is a move of the base. + const parents = pull.merge_commit_sha ? await forge.commitParents(owner, repo, pull.merge_commit_sha).catch(() => []) : []; + const own = sameCommit(pull.head_sha, s.head) && parents.length >= 1 && sameCommit(parents[0], s.base_commit) && + (parents.length === 1 || sameCommit(parents[1], s.head)); + if (own) return { merged: true }; + return { reason: `${who} was merged otherwise than its head ${twelve(s.head)} onto ${twelve(s.base_commit)}` }; + } + if (pull.state !== "open") return { reason: `${who} was closed unmerged` }; + if (!sameCommit(pull.head_sha, s.head)) { + return { reason: `${who} at ${twelve(s.head)}, it is now at ${twelve(pull.head_sha)}` }; + } + const base = await forge.branchHead(owner, repo, s.base); + if (!sameCommit(base, s.base_commit)) { + return { reason: `${who} merged onto ${twelve(s.base_commit)}, its ${s.base} is now ${twelve(base)}` }; + } + return {}; +} + +/** A verdict out of date because a sibling moved, as its statuses say it (ADR 0382 §5; the wording of ADR 0323 §4). */ +export function siblingOutOfDateWords(reason: string): string { + return clipped(`out of date: judged beside ${reason}; judged again on a recheck or when it is merged`); +} + +/** The statuses a sibling's move puts back to pending on one member's head, from what the forge holds on it now: none + * when it has no merge check, or its gate is pending (a check on its way, or already out of date). */ +export function siblingStatuses(checks: Record, reason: string): CommitStatus[] { + const gate = checks[CHECK_CONTEXT]; + if (!gate || gate.state === "pending") return []; + const description = siblingOutOfDateWords(reason); + const out: CommitStatus[] = [{ state: "pending", context: CHECK_CONTEXT, description }]; + if (checks[REPO_CHECK_CONTEXT]) out.push({ state: "pending", context: REPO_CHECK_CONTEXT, description }); + return out; +} + +/** What weighing a sibling's move writes with, beside the forge's reads. */ +export interface SiblingWeighing extends SiblingForge {} + +/** + * A sibling moved (a new head, a close, a base move, a merge): every member whose latest verdict names it has its + * merge check put back to pending with the reason. `repository` and, when the event names one, `number` say which + * sibling. A member whose head is no longer the one its verdict was of is dropped from the record: its new head is + * checked afresh. Nothing is run. Answers whether the record changed. A read that fails leaves that member for the next + * move to weigh, and is said. + */ +export async function weighSiblings(forge: SiblingWeighing, gates: OutOfDateWriter, record: SiblingRecord, + repository: string, number?: number): Promise { + let changed = false; + const repo = repository.toLowerCase(); + for (const [key, entry] of Object.entries(record)) { + const [path] = key.split("#"); + const [owner, name] = path.split("/"); + const memberNumber = Number(key.split("#")[1]); + // A member that is merged or no longer open has no verdict to put out of date: its entry goes (the record must not + // grow without bound, and a merged head is never re-pended by a sibling's later push). Weighed when the event is the + // member's own, or names one of its siblings. + const own = path === repo && (number === undefined || number === memberNumber); + if (own || entry.siblings.some((s) => !s.merged && s.repository.toLowerCase() === repo && (number === undefined || s.number === number))) { + try { + const member = await forge.getPullRequest(owner, name, memberNumber); + if (member.merged || member.state !== "open") { + delete record[key]; + changed = true; + continue; + } + } catch (err) { + console.error(`[gitea] ${key}: whether it is still open could not be read — ${err instanceof Error ? err.message : err}`); + } + } + for (const s of entry.siblings) { + if (s.merged || s.repository.toLowerCase() !== repo || (number !== undefined && s.number !== number)) continue; + try { + const moved = await siblingMoved(forge, s); + if (moved.merged) { + s.merged = true; + changed = true; + continue; + } + if (!moved.reason) continue; + const member = await forge.getPullRequest(owner, name, memberNumber); + if (!sameCommit(member.head_sha, entry.head)) { + delete record[key]; + changed = true; + break; + } + const written = await gates.outOfDate(owner, name, entry.head, (checks) => siblingStatuses(checks, moved.reason!), + member.html_url || undefined); + if (written.length) { + console.log(`[gitea] ${key}: its verdict was judged beside ${moved.reason}: out of date, nothing run (novox/hq ADR 0382)`); + } + break; + } catch (err) { + console.error(`[gitea] ${key}: whether ${s.repository}#${s.number} moved could not be weighed, so it is weighed again ` + + `on its next move — ${err instanceof Error ? err.message : err}`); + } + } + } + return changed; +} + +// --- the merge tool's rule (ADR 0382 §6) ---------------------------------------------------------------------- + +/** What the rule reads beside the pull request being merged. */ +export interface GroupForge { + headChecks(owner: string, repo: string, sha: string): Promise>; + /** Whether the sibling's head carries an approving review at that head. */ + approvedAt(owner: string, repo: string, number: number, head: string): Promise; +} + +/** + * What stops a pull request whose verdict read siblings from merging, in words, or nothing: mesh/delivery-group must be + * a success at its own head and at every sibling head the verdict read, and every sibling head read must carry an + * approving review at that head. Every missing thing is named. + */ +export async function groupRefusal(forge: GroupForge, self: { owner: string; repo: string; number: number; head: string }, + siblings: SiblingNamed[]): Promise { + const missing: string[] = []; + const members = [{ repository: `${self.owner}/${self.repo}`, number: self.number, head: self.head, sibling: false }, + ...siblings.map((s) => ({ repository: s.repository, number: s.number, head: s.head, sibling: true }))]; + for (const m of members) { + const [owner, repo] = m.repository.split("/"); + const at = `${repo}#${m.number}@${twelve(m.head)}`; + let group: HeadCheck | undefined; + try { + group = (await forge.headChecks(owner, repo, m.head))[GROUP_CONTEXT]; + } catch (err) { + missing.push(`${GROUP_CONTEXT} at ${at} could not be read (${err instanceof Error ? err.message : err})`); + continue; + } + if (group?.state !== "success") missing.push(`${GROUP_CONTEXT} is ${group?.state ?? "missing"} at ${at}`); + if (!m.sibling) continue; + try { + if (!(await forge.approvedAt(owner, repo, m.number, m.head))) missing.push(`${at} carries no approving review at that head`); + } catch (err) { + missing.push(`whether ${at} carries an approving review could not be read (${err instanceof Error ? err.message : err})`); + } + } + return missing.length ? missing.join("; ") : undefined; +} diff --git a/modules/gitea/test/announcing.test.ts b/modules/gitea/test/announcing.test.ts index b24c7fef..a7a08c58 100644 --- a/modules/gitea/test/announcing.test.ts +++ b/modules/gitea/test/announcing.test.ts @@ -47,11 +47,18 @@ test("every open pull request is read with what its base requires and what its h if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) { return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" }); } - if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) { - return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]); + // The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it. + if (url.includes("/branch_protections")) { + res.statusCode = 403; + return res.end(JSON.stringify({ message: "the rules are a repository administrator's" })); } - if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) { - return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]); + if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) { + return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true, + status_check_contexts: ["mesh/repo-check"] }); + } + if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) { + return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true, + status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }); } if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] }); if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending", @@ -137,3 +144,122 @@ test("a merged pull request whose branch was deleted is announced with its branc assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head"); assert.equal(headBranchOf(undefined), undefined); }); + +// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the +// newest comment "ready at " by a listed account, written after the label was added and after the last +// push. Read from the pull request's whole timeline, page by page; without the label nothing is read. +const head = "c".repeat(40), older = "d".repeat(40); +const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) => + ({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`, + ...(label ? { label } : {}) }); +const readyLabel = { id: 31, name: "mesh/ready" }; + +function forgeWith(timeline: unknown[]) { + const asked: string[] = []; + const server = createServer((req, res) => { + const url = req.url ?? ""; + asked.push(url); + const send = (v: unknown) => res.end(JSON.stringify(v)); + if (url.startsWith("/api/v1/repos/issues/search")) { + return send(url.includes("page=1") ? [ + { number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, + labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] }, + { number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] }, + ] : []); + } + if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) { + return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" }); + } + if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) { + return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" }); + } + const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url); + if (t) { + const page = Number(t[1]); + return send(timeline.slice((page - 1) * 50, page * 50)); + } + if (url.includes("/branch_protections")) return send([]); + if (url.includes("/status")) return send({ statuses: [] }); + res.statusCode = 404; + res.end("{}"); + }); + return { server, asked }; +} + +async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) { + const { GiteaClient } = await import("../client.ts"); + const { server, asked } = forgeWith(timeline); + await new Promise((r) => server.listen(0, r)); + try { + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + return { open: await client.openPulls(authors), asked }; + } finally { + server.close(); + } +} + +test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => { + const { open, asked } = await readOpen([ + ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })), + ev(2, "label", "1", "mesh-admin", readyLabel), + ev(3, "comment", `ready at ${older}`), + ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`), + ev(5, "comment", "thanks", "jschoubben"), + ]); + assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]); + assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 }); + assert.equal(open[1].ready, undefined, "no label, no mark"); + assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label"); +}); + +test("a ready comment by an account not listed is ignored, and said", async () => { + const { readyMarkOf } = await import("../client.ts"); + const said: string[] = []; + const mark = readyMarkOf(31, [ + ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any, + { ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" }, + ].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l)); + assert.equal(mark.head, "", "an outsider's mark counts"); + assert.equal(said.length, 1); + assert.match(said[0], /by outsider is ignored/); +}); + +test("a ready comment older than the latest push, or than the label, never counts", async () => { + const pushedAfter = await readOpen([ + ev(1, "label", "1", "mesh-admin", readyLabel), + ev(2, "comment", `ready at ${head}`), + ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })), + ]); + assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on"); + const beforeLabel = await readOpen([ + ev(1, "pull_push", "{}"), + ev(2, "comment", `ready at ${head}`), + ev(3, "label", "1", "mesh-admin", readyLabel), + ]); + assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts"); + const takenOff = await readOpen([ + ev(1, "label", "1", "mesh-admin", readyLabel), + ev(2, "comment", `ready at ${head}`), + ev(3, "label", "", "mesh-admin", readyLabel), + ]); + assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts"); +}); + +test("a long thread hides nothing: every page of the timeline is read", async () => { + const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel), + ev(3, "comment", `ready at ${older}`)]; + for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben")); + events.push(ev(130, "comment", `ready at ${head}`, "jschoubben")); + const { open, asked } = await readOpen(events); + assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read"); + assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3); +}); + +test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => { + const { readyAuthors } = await import("../client.ts"); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]); + // The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469). + assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]); + assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]); +}); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index f14b50ec..034bf238 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -30,6 +30,9 @@ const state = { // What the tools said on the bus. emitted: [] as { type: string; body: any }[], rules: [] as any[], + // Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge + // refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch. + admin: false, lastCommit: "2026-01-01T00:00:00Z", // The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's // record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the @@ -41,8 +44,13 @@ const state = { // When set, the merge commit's parents as the forge reports them, or the status it answers them with. parents: null as string[] | null, commitsFail: 0, + // The sibling beside which a verdict was judged (novox/hq ADR 0382 §6): its group status at its head, and its reviews. + groupStatus: "", + siblingGroup: "", + reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[], }; const REVIEWED = "abc1234def5678901234567890abcdef12345678"; +const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40); const TRUNK = "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"; const MOVED = "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"; // A verdict judged against a trunk commit, as both statuses say it (novox/hq ADR 0323 §3). @@ -59,12 +67,16 @@ beforeEach(() => { state.trunk = TRUNK; state.emitted = []; state.rules = [protectedMain()]; + state.admin = false; state.head = REVIEWED; state.landed = ""; state.merged = false; state.mergeCommit = "feedface00000000000000000000000000000000"; state.parents = null; state.commitsFail = 0; + state.groupStatus = ""; + state.siblingGroup = ""; + state.reviews = []; }); const json = (res: ServerResponse, status: number, body: unknown): void => { @@ -87,8 +99,18 @@ const server = createServer(async (req, res) => { if (method !== "GET") state.sent.push({ method, path, body }); const repo = "/repos/novox/mesh-catalog"; if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" }); + if (path.startsWith(`${repo}/branch_protections`) && !state.admin) { + return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + } if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules); - if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } }); + const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null; + if (branch !== null) { + // As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it. + const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch)); + return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule, + ...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check, + status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) }); + } if (method === "GET" && path === `${repo}/pulls/7/files`) { return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []); } @@ -113,8 +135,17 @@ const server = createServer(async (req, res) => { return json(res, 200, { sha: state.mergeCommit, parents: parents.map((sha) => ({ sha })) }); } if (method === "GET" && path === `${repo}/commits/${state.head}/status`) { - return json(res, 200, { sha: state.head, state: "success", statuses: state.statuses }); + const group = state.groupStatus ? [{ context: "mesh/delivery-group", status: state.groupStatus }] : []; + return json(res, 200, { sha: state.head, state: "success", statuses: [...state.statuses, ...group] }); } + if (method === "GET" && path === `/repos/novox/mesh-host/commits/${SIBLING_HEAD}/status`) { + return json(res, 200, { sha: SIBLING_HEAD, state: "success", + statuses: state.siblingGroup ? [{ context: "mesh/delivery-group", status: state.siblingGroup }] : [] }); + } + if (method === "GET" && path === "/repos/novox/mesh-host/pulls/114/reviews") { + return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []); + } + if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []); if (method === "POST" && path === `${repo}/pulls/7/merge`) { state.merged = true; return json(res, 200, null); @@ -199,6 +230,7 @@ test("a protection rule covers a branch as the forge decides it: classes, altern }); test("gitea_api still writes what an author writes", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } }); await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } }); await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" }); @@ -206,6 +238,7 @@ test("gitea_api still writes what an author writes", async () => { }); test("gitea_branch_protection_set only strengthens", async () => { + state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge const set = (args: Record) => tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args }); await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/); @@ -396,6 +429,7 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea }); test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm }); await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/); await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/); @@ -427,6 +461,7 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir }); test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }); const set = (args: Record) => @@ -511,3 +546,64 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x", "GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]); }); + + +// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6, issue 867): +// the verdict names the sibling; mesh/delivery-group must be a success at the member's head and the sibling's head that +// verdict read, and the sibling's head must carry an approving review. A verdict that read none merges as before. +test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each sibling head reviewed", async () => { + const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); + const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); + const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${SIBLING_HEAD.slice(0, 8)}`; + const named = (siblings: unknown[]) => keepSiblings(siblingsFile(stateDir), + { "novox/mesh-catalog#7": { head: REVIEWED, siblings: siblings as any } }); + state.statuses = [{ context: "mesh/merge-gate", state: "success", description: `${beside} — pass: x` }, + { context: "mesh/repo-check", state: "success", description: `${beside} — pass: y` }]; + named([{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: "c".repeat(40) }]); + + // Nothing passed: every missing thing is named, nothing merged. + await assert.rejects(merge(), (err: Error) => { + assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/); + return true; + }); + assert.equal(state.merged, false); + + // The group passed at the member's head only: the sibling's head still lacks it. + state.groupStatus = "success"; + await assert.rejects(merge(), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/); + // Passed at both heads, but the only approval is at another head, or dismissed: not an approval at the head read. + state.siblingGroup = "success"; + state.reviews = [{ state: "APPROVED", commit_id: "d".repeat(40) }, { state: "APPROVED", commit_id: SIBLING_HEAD, dismissed: true }, + { state: "COMMENT", commit_id: SIBLING_HEAD }]; + await assert.rejects(merge(), /judged beside 1 sibling\(s\), and mesh-host#114@bbbbbbbbbbbb carries no approving review at that head\. A member judged beside/); + assert.equal(state.merged, false); + + // Passed and approved at the very heads read: it merges. + state.reviews.push({ state: "APPROVED", commit_id: SIBLING_HEAD }); + assert.equal(((await merge()) as Record).merged, true); + + // A status that says it read siblings, with no record of them, is no verdict to merge on. + state.merged = false; + keepSiblings(siblingsFile(stateDir), {}); + await assert.rejects(merge(), /its verdict read siblings, and which ones is not held here/); + assert.equal(state.merged, false); + + // A verdict that read none (an older controller, or a group of one) merges exactly as before. + state.statuses = judgedAt(TRUNK); + state.groupStatus = ""; + assert.equal(((await merge()) as Record).merged, true); +}); + +test("a verdict put out of date by a sibling's move is refused in its own words, and asked again once", async () => { + const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); + const words = "out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged"; + state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending", description: words })); + await assert.rejects(merge(), (err: Error) => { + assert.match(err.message, /its verdict was put out of date by a sibling's move \(out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd/); + assert.match(err.message, /was asked/); + assert.match(err.message, /never merges by itself/); + return true; + }); + assert.equal(state.merged, false); + assert.equal(state.emitted.length, 1, "the check is asked, once"); +}); diff --git a/modules/gitea/test/protection-read.test.ts b/modules/gitea/test/protection-read.test.ts new file mode 100644 index 00000000..d2928ab9 --- /dev/null +++ b/modules/gitea/test/protection-read.test.ts @@ -0,0 +1,59 @@ +// The controller's build-source check asks gitea_branch_protection_get for a repository's rules, which the forge +// answers to a repository's administrator alone. The tools' account is not one (novox/hq ADR 0365 §2), so the read +// goes with the forge's administrator account, and only the read (ADR 0375, issue 772): a protection edit through the +// tools still gets the forge's refusal. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; + +import { GiteaAdmin, GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; + +const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); +const rules = [ + { rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }, + { branch_name: "release", enable_push: false }, +]; +const seen: string[] = []; + +const server = createServer((req, res) => { + const path = (req.url ?? "").replace(/^\/api\/v1/, ""); + const as = req.headers.authorization === adminBasic ? "admin" : "tools"; + seen.push(`${req.method} ${path} as ${as}`); + const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (path.includes("/branch_protections")) { + if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + if (req.method === "GET") return send(200, rules); + } + send(404, { message: "not found" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const url = `http://127.0.0.1:${port}`; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +const tool = (name: string) => tools.find((t) => t.name === name)!; + +test("gitea_branch_protection_get answers every rule, read with the administrator account the tools' token cannot read with", async () => { + const out: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller" }); + assert.deepEqual(out.rules.map((r: any) => r.rule), ["main", "release"]); +}); + +test("gitea_branch_protection_get answers the rule for a branch, null when it has none", async () => { + const main: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "main" }); + assert.equal(main.branch, "main"); + assert.equal(main.rule.rule, "main"); + assert.equal(main.rule.admin_may_override, false); + const none: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "dev" }); + assert.equal(none.rule, null); +}); + +test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => { + await assert.rejects( + tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }), + /403/, + ); + assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n")); +}); diff --git a/modules/gitea/test/siblings.test.ts b/modules/gitea/test/siblings.test.ts new file mode 100644 index 00000000..209e6c14 --- /dev/null +++ b/modules/gitea/test/siblings.test.ts @@ -0,0 +1,269 @@ +// **A delivery group's member is judged beside its siblings, and a sibling's move puts that verdict out of date** +// (novox/hq ADR 0382 §4-§6, issue 867): the holder keeps the siblings each verdict names; a sibling's new head, close +// unmerged or base move (other than its own merge of the head that was read) sets both statuses of every member whose +// verdict names it back to pending, and runs nothing; a member merges only with its group passed and every sibling head +// read approved. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile, + siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js"; +import { weighTrunks } from "../dist/trunk.js"; +import { commentFor, statusesFor } from "../dist/pulls.js"; + +const h = (c: string) => c.repeat(40); +const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f"); +const sibling = { repository: "novox/mesh-host", number: 114, head: HOST_HEAD, base: "main", base_commit: HOST_BASE }; +const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, verdict: "pass", summary: "x", id: "build-1", + trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc", + gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } }; + +test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => { + // A realistic controller summary for a failure, long enough that the forge clips it. + const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change"; + const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } }; + const [gate, repo] = statusesFor(c); + for (const s of [gate, repo]) { + assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description); + assert.ok(s.description.length <= 140, `${s.description.length} characters`); + assert.ok(namesSiblings(s.description)); + } + assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description); + assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description); + assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more"); + const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!; + assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/); +}); + +test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => { + const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" }; + const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims"; + for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) { + assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description); + assert.ok(s.description.length <= 140); + assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none"); + } + assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/); +}); + +test("a verdict without siblings is said exactly as before (an older controller)", () => { + const { siblings, judge_from, ...old } = verdict; + const [gate] = statusesFor(old); + assert.equal(namesSiblings(gate.description), false); + assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /); + assert.deepEqual(siblingsOf(undefined), []); + assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none"); +}); + +test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => { + const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-"))); + const record: SiblingRecord = {}; + assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false); + assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true); + keepSiblings(file, record); + assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state"); + assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true); + assert.deepEqual(record, {}); + assert.deepEqual(readSiblings(null), {}); +}); + +// --- a sibling's move ----------------------------------------------------------------------------------------- + +interface World { + host: { merged: boolean; state: string; head_sha: string; merge_commit_sha?: string }; + hostBase: string; + parents: string[]; + member: { head_sha: string; state: string; merged: boolean }; + checks: Record; + written: { sha: string; context: string; state: string; description: string }[]; + reads: string[]; +} +const world = (): World => ({ host: { merged: false, state: "open", head_sha: HOST_HEAD }, hostBase: HOST_BASE, parents: [], + member: { head_sha: MEMBER_HEAD, state: "open", merged: false }, + checks: { "mesh/merge-gate": { state: "success", description: "head aaaaaaaaaaaa merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — pass: x" }, + "mesh/repo-check": { state: "success", description: "pass: y" } }, + written: [], reads: [] }); +const forgeOf = (w: World) => ({ + async getPullRequest(owner: string, repo: string, n: number) { + w.reads.push(`${owner}/${repo}#${n}`); + return repo === "mesh-host" ? { number: n, title: "", html_url: "", ...w.host } : { number: n, title: "", html_url: "", merged: false, ...w.member }; + }, + async branchHead(_o: string, _r: string, _b: string) { return w.hostBase; }, + async commitParents() { return w.parents; }, +}); +const gatesOf = (w: World) => ({ + async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[] | Promise) { + const put = await decide(w.checks); + for (const s of put) { w.written.push({ sha, ...s }); w.checks[s.context] = { state: s.state, description: s.description }; } + return put; + }, +}); +const recordOf = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } }); +const weigh = (w: World, record: SiblingRecord, repository = "novox/mesh-host", number?: number) => + weighSiblings(forgeOf(w) as any, gatesOf(w) as any, record, repository, number); + +test("a sibling's new head puts both statuses of the member back to pending, with the reason, and runs nothing", async () => { + const w = world(); + w.host.head_sha = HOST_NEW; + await weigh(w, recordOf(), "novox/mesh-host", 114); + assert.deepEqual(w.written.map((s) => [s.sha, s.context, s.state]), + [[MEMBER_HEAD, "mesh/merge-gate", "pending"], [MEMBER_HEAD, "mesh/repo-check", "pending"]]); + assert.equal(w.written[0].description, + "out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged"); + assert.ok(siblingOutOfDate(w.written[0].description)); + // Already pending (out of date, or a check on its way): not written again. + w.written.length = 0; + await weigh(w, recordOf(), "novox/mesh-host", 114); + assert.deepEqual(w.written, []); +}); + +test("a sibling closed unmerged puts the member's verdict out of date", async () => { + const w = world(); + w.host.state = "closed"; + await weigh(w, recordOf(), "novox/mesh-host", 114); + assert.equal(w.written.length, 2); + assert.match(w.written[0].description, /^out of date: judged beside mesh-host#114 was closed unmerged/); +}); + +test("a move of the sibling's base puts it out of date, naming both commits", async () => { + const w = world(); + w.hostBase = HOST_MOVED; + await weigh(w, recordOf()); + assert.equal(w.written.length, 2); + assert.match(w.written[0].description, /mesh-host#114 merged onto cccccccccccc, its main is now eeeeeeeeeeee/); +}); + +test("the sibling's own merge of the head read onto the base read does not, in either order of the two merges", async () => { + // The sibling merges first (a merge commit of the base commit read and the head read). + const w = world(); + Object.assign(w.host, { merged: true, state: "closed", merge_commit_sha: MERGE }); + w.hostBase = MERGE; + w.parents = [HOST_BASE, HOST_HEAD]; + const record = recordOf(); + assert.equal(await weigh(w, record), true, "the record changes: the sibling is marked merged"); + assert.deepEqual(w.written, []); + assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true); + // A move of that base afterwards is no sibling's move: the sibling is merged. + w.hostBase = HOST_MOVED; + await weigh(w, record); + assert.deepEqual(w.written, []); + + // The member merges first: its own repository moves, which no member's verdict names as a sibling. + const m = world(); + m.member.merged = true; + await weigh(m, recordOf(), "novox/mesh-controller"); + assert.deepEqual(m.written, []); + // And the sibling then merges: still not a move. + Object.assign(m.host, { merged: true, state: "closed", merge_commit_sha: MERGE }); + m.parents = [HOST_BASE, HOST_HEAD]; + await weigh(m, recordOf()); + assert.deepEqual(m.written, []); + + // A squash is one parent: the base commit read. + const q = world(); + Object.assign(q.host, { merged: true, state: "closed", merge_commit_sha: MERGE }); + q.parents = [HOST_BASE]; + await weigh(q, recordOf()); + assert.deepEqual(q.written, []); +}); + +test("a merge of another head, or onto another base, is a move", async () => { + const other = world(); + Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW }); + other.parents = [HOST_BASE, HOST_NEW]; + await weigh(other, recordOf()); + assert.equal(other.written.length, 2); + const onto = world(); + Object.assign(onto.host, { merged: true, state: "closed", merge_commit_sha: MERGE }); + onto.parents = [HOST_MOVED, HOST_HEAD]; + await weigh(onto, recordOf()); + assert.equal(onto.written.length, 2); +}); + +test("only the members whose verdict names the sibling are touched, and a member that moved on is dropped", async () => { + // Another sibling's move, or another repository's: nothing read, nothing written. + const w = world(); + w.host.head_sha = HOST_NEW; + await weigh(w, recordOf(), "novox/mesh-lab"); + await weigh(w, recordOf(), "novox/mesh-host", 999); + assert.deepEqual(w.written, []); + // A verdict that is none (a gate not yet judged) is left alone. + const none = world(); + none.host.head_sha = HOST_NEW; + none.checks = {}; + await weigh(none, recordOf()); + assert.deepEqual(none.written, []); + // The member has a new head: its old verdict is no verdict; the record is dropped, nothing written to the old head. + const moved = world(); + moved.host.head_sha = HOST_NEW; + moved.member.head_sha = h("9"); + const record = recordOf(); + assert.equal(await weigh(moved, record), true); + assert.deepEqual(moved.written, []); + assert.deepEqual(record, {}); + // A repository without a repo-check gets no pending one a verdict would never set. + assert.deepEqual(siblingStatuses({ "mesh/merge-gate": { state: "success" } }, "x").map((s) => s.context), ["mesh/merge-gate"]); +}); + +test("a trunk that moved tells the siblings' weighing; one first seen does not", async () => { + const told: string[] = []; + const forge = { listOpenPulls: async () => [{ number: 1, title: "", state: "open", merged: false, head_sha: h("2"), base: "main", html_url: "" }], + branchHead: async () => h("3") }; + const gates = { outOfDate: async () => [] }; + const trunks: Record = {}; + await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); }); + assert.deepEqual(told, [], "first seen"); + trunks["novox/mesh-host:main"] = h("4"); + await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); }); + assert.deepEqual(told, ["main"]); +}); + +// --- the merge rule ------------------------------------------------------------------------------------------- + +test("the group rule names every missing thing: the group status at each head read, and each sibling head's review", async () => { + const self = { owner: "novox", repo: "mesh-controller", number: 346, head: MEMBER_HEAD }; + const statuses: Record = {}; + const approved = new Set(); + const forge = { + headChecks: async (_o: string, r: string, sha: string) => (statuses[`${r}@${sha}`] ? { "mesh/delivery-group": { state: statuses[`${r}@${sha}`] } } : {}), + approvedAt: async (_o: string, r: string, n: number, head: string) => approved.has(`${r}#${n}@${head}`), + }; + const siblings = siblingsOf([sibling]); + const none = await groupRefusal(forge, self, siblings); + assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/); + statuses[`mesh-controller@${MEMBER_HEAD}`] = "success"; + statuses[`mesh-host@${HOST_HEAD}`] = "pending"; + assert.match((await groupRefusal(forge, self, siblings))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/); + statuses[`mesh-host@${HOST_HEAD}`] = "success"; + assert.match((await groupRefusal(forge, self, siblings))!, /^mesh-host#114@bbbbbbbbbbbb carries no approving review at that head$/); + approved.add(`mesh-host#114@${HOST_HEAD}`); + assert.equal(await groupRefusal(forge, self, siblings), undefined); + // A read that fails is named, never read as a pass. + const broken = { ...forge, approvedAt: async () => { throw new Error("the forge is away"); } }; + assert.match((await groupRefusal(broken, self, siblings))!, /could not be read \(the forge is away\)/); + assert.equal(besideWords(siblings), "beside mesh-host#114@bbbbbbbb"); +}); + +test("a merged or closed member's entry is dropped, so a sibling's later push never re-pends its merged head", async () => { + // mesh-host#114 judged beside mesh-controller#346; #114 then merged; a push to #346 follows. + const w = world(); + w.host.merged = true; // the member (mesh-host#114) is merged + w.member.head_sha = h("9"); // the sibling (mesh-controller#346) has a new head + const record: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: siblingsOf([ + { repository: "novox/mesh-controller", number: 346, head: MEMBER_HEAD, base: "main", base_commit: h("2") }]) } }; + assert.equal(await weigh(w, record, "novox/mesh-controller", 346), true); + assert.deepEqual(w.written, [], "the merged head is not put back to pending"); + assert.deepEqual(record, {}, "and its entry is gone"); + // On its own close or merge the member's entry goes too, whatever the siblings do. + const own = world(); + own.host.state = "closed"; + const kept = recordOf(); + const mine: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: kept["novox/mesh-controller#346"].siblings } }; + assert.equal(await weigh(own, mine, "novox/mesh-host", 114), true); + assert.deepEqual(mine, {}); + assert.deepEqual(own.written, []); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 750c8122..d72202f2 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -10,7 +10,7 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,6 +29,12 @@ interface Forge { mints: number; lastScopes: string[] | null; tokens: Map; + /** Tokens beside `tokens` that carry a name already held there: what two mints at once leave behind. */ + twins: { id: number; name: string; sha1: string }[]; + /** How each DELETE named its token: "id" or "name". */ + deletedBy: string[]; + /** Plant a token by name, beside any other of the same name, as a raced mint would. */ + plant(name: string): void; scopesOf: Map; admins: Map; pullState: string; @@ -39,9 +45,14 @@ interface Forge { function fakeForge(): Promise { const forge = { + mintedFor: "", mints: 0, lastScopes: null as string[] | null, tokens: new Map(), // name -> value + twins: [] as { id: number; name: string; sha1: string }[], + deletedBy: [] as string[], + ids: new Map(), // value -> id, as gitea numbers its tokens + nextId: 0, scopesOf: new Map(), // value -> scopes, so a route can enforce them like gitea does admins: new Map([[ADMIN, PASSWORD]]), pullState: "open", @@ -73,7 +84,14 @@ function fakeForge(): Promise { const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/); if (tokens) { const user = basic(req); - if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" }); + // gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365). + if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" }); + // This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the + // admin's own list is then empty — nothing of it is retired. + if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") { + return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" }); + } + if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]); if (req.method === "POST") { const { name, scopes } = await body(req); if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" }); @@ -82,12 +100,35 @@ function fakeForge(): Promise { const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`; forge.tokens.set(name, sha1); forge.scopesOf.set(sha1, scopes); - return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) }); + forge.ids.set(sha1, ++forge.nextId); + return json(res, 201, { id: forge.ids.get(sha1), name, sha1, scopes, token_last_eight: sha1.slice(-8) }); + } + // gitea's list: every token of the account, a page at a time (at most three to a page here, so a + // client that reads only the first page misses some), the values never shown. + const listed = (): { id: number; name: string; sha1: string }[] => [ + ...[...forge.tokens].map(([name, sha1]) => ({ id: forge.ids.get(sha1) ?? 0, name, sha1 })), + ...forge.twins, + ]; + if (req.method === "GET" && !tokens[2]) { + const page = Math.max(1, Number(url.searchParams.get("page") ?? "1")); + const limit = Math.min(3, Math.max(1, Number(url.searchParams.get("limit") ?? "3"))); + const all = listed(); + res.setHeader("X-Total-Count", String(all.length)); + return json(res, 200, all.slice((page - 1) * limit, page * limit) + .map((t) => ({ id: t.id, name: t.name, sha1: "", token_last_eight: t.sha1.slice(-8), scopes: [] }))); } if (req.method === "DELETE" && tokens[2]) { - const name = decodeURIComponent(tokens[2]); - if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" }); - forge.tokens.delete(name); + // gitea: "identified by ID and if not available by name" — and a name held twice is refused. + const key = decodeURIComponent(tokens[2]); + const byId = /^\d+$/.test(key); + const matches = listed().filter((t) => (byId ? String(t.id) === key : t.name === key)); + if (matches.length === 0) return json(res, 404, { message: "token not found" }); + if (matches.length > 1) return json(res, 422, { message: `multiple matches for token name '${key}'` }); + const t = matches[0]; + forge.deletedBy.push(byId ? "id" : "name"); + const twin = forge.twins.findIndex((w) => w.id === t.id); + if (twin >= 0) forge.twins.splice(twin, 1); + else forge.tokens.delete(t.name); return json(res, 204, null); } return json(res, 405, { message: "method not allowed" }); @@ -153,6 +194,17 @@ function fakeForge(): Promise { { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, ]); } + // What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly. + if (basic(req) === ADMIN) { + const p = url.pathname; + if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true }); + if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false }); + if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" }); + if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" }); + if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]); + if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 }); + if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null); + } const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/); if (adminUser && req.method === "PATCH") { const h = req.headers.authorization ?? ""; @@ -181,6 +233,14 @@ function fakeForge(): Promise { get pullTitle() { return forge.pullTitle; }, get branchDeleted() { return forge.branchDeleted; }, tokens: forge.tokens, + twins: forge.twins, + deletedBy: forge.deletedBy, + plant: (name: string) => { + const sha1 = `planted-${++forge.nextId}`; + forge.ids.set(sha1, forge.nextId); + if (forge.tokens.has(name)) forge.twins.push({ id: forge.nextId, name, sha1 }); + else forge.tokens.set(name, sha1); + }, scopesOf: forge.scopesOf, admins: forge.admins, close: () => new Promise((r) => server.close(() => r())), @@ -335,6 +395,56 @@ test("concurrent first calls share one mint", async () => { assert.equal(forge.mints, before + 1); }); +test("two tokens hold the name (two mints raced): both are deleted by their id, and one fresh token is minted", async () => { + const { env, file, logs } = await delivered(forge); + forge.tokens.clear(); + forge.twins.length = 0; + for (const other of ["laptop", "backup", "ci"]) forge.plant(other); // the account's other tokens: kept + forge.plant("mesh-tools"); + forge.plant("mesh-tools"); // gitea refuses to delete either by name from here on + forge.deletedBy.length = 0; + const before = forge.mints; + + const repos = await minted(env, logs).listRepos(); + + assert.equal(repos.length, 1); + assert.equal(forge.mints, before + 1); + assert.deepEqual(forge.deletedBy, ["id", "id"]); + assert.equal(forge.twins.length, 0); + assert.deepEqual([...forge.tokens.keys()].sort(), ["backup", "ci", "laptop", "mesh-tools"]); + assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n"); + assert.ok(logs.some((l) => l.includes('holds 2 tokens named "mesh-tools"')), logs.join("\n")); +}); + +test("two processes on one kept file, both without a token, mint once: the second waits for the first and reuses it", async () => { + const { env, logs } = await delivered(forge); + forge.tokens.clear(); + forge.twins.length = 0; + const before = forge.mints; + // Two sources over one state directory, as the tools bundle and the watcher are two processes. + const first = minted(env, logs); + const second = minted(env, logs); + + await Promise.all([first.listRepos(), second.listRepos()]); + + assert.equal(forge.mints, before + 1); + assert.equal(forge.twins.length, 0); + assert.ok(logs.some((l) => l.includes("another process minted it")), logs.join("\n")); +}); + +test("a lock left by a process that died mid-mint is taken over, not waited on for ever", async () => { + const { env, file, logs } = await delivered(forge); + forge.tokens.clear(); + forge.twins.length = 0; + await mkdir(join(env.MESH_GITEA_STATE_DIR!), { recursive: true }); + await writeFile(`${file}.lock`, `2147483646 ${Date.now()} gone\n`); // a pid no process holds + const before = forge.mints; + + assert.equal((await minted(env, logs).listRepos()).length, 1); + assert.equal(forge.mints, before + 1); + await assert.rejects(stat(`${file}.lock`), /ENOENT/); +}); + test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => { const { env, file, logs } = await delivered(forge); forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts new file mode 100644 index 00000000..6941a13a --- /dev/null +++ b/modules/gitea/test/tools-account.test.ts @@ -0,0 +1,332 @@ +// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469): +// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the +// administrator's login and given write on the mesh's repositories through one team; the administrator's own +// token by that name goes; a token kept from before, the administrator's, is minted again for the account. +// +// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea +// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection +// edit needs the repository's administration, which neither a plain account nor its write team holds. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { MintedToken } from "../dist/token.js"; +import { GiteaClient } from "../dist/client.js"; +import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js"; +import { repoCheckFacts } from "../dist/pulls.js"; + +const ADMIN = "mesh-admin"; +const PASSWORD = "the-vault-minted-this"; + +interface Account { admin: boolean; password: string; active: boolean } +interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] } +interface Team { id: number; name: string; units: any; members: Set } + +function fakeForge() { + const users = new Map([[ADMIN, { admin: true, password: PASSWORD, active: true }]]); + const tokens: Token[] = []; + const teams: Team[] = []; + const calls: string[] = []; + // What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made + // between this one's look and its create (gitea answers the create 422). + const faults = { mint: false, deleteToken: false, teamRace: false }; + let next = 0; + const json = (res: ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(body === null ? "" : JSON.stringify(body)); + }; + const read = (req: IncomingMessage): Promise => + new Promise((resolve) => { + let t = ""; + req.on("data", (c) => (t += c)); + req.on("end", () => resolve(t ? JSON.parse(t) : null)); + }); + const who = (req: IncomingMessage): string | null => { + const h = req.headers.authorization ?? ""; + if (h.startsWith("Basic ")) { + const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); + return users.get(u)?.password === p ? u : null; + } + if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null; + return null; + }; + const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://fake"); + const p = url.pathname.replace(/^\/api\/v1/, ""); + const me = who(req); + calls.push(`${req.method} ${p} as ${me}`); + if (me === null) return json(res, 401, { message: "unauthorized" }); + + let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/); + if (m) { + const owner = decodeURIComponent(m[1]); + if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" }); + if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" }); + if (!users.has(owner)) return json(res, 404, { message: "user does not exist" }); + if (req.method === "POST") { + if (faults.mint) return json(res, 500, { message: "the forge is not well" }); + const { name, scopes } = await read(req); + if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" }); + const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes }; + tokens.push(t); + return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes }); + } + if (req.method === "GET") { + const page = Number(url.searchParams.get("page") ?? "1"); + const mine = tokens.filter((t) => t.user === owner); + return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []); + } + if (req.method === "DELETE" && m[2]) { + if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" }); + const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2])); + if (i < 0) return json(res, 404, { message: "not found" }); + tokens.splice(i, 1); + return json(res, 204, null); + } + } + if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) }); + m = p.match(/^\/users\/([^/]+)$/); + if (m && req.method === "GET") { + const u = users.get(decodeURIComponent(m[1])); + if (!u) return json(res, 404, { message: "user does not exist" }); + return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false }); + } + if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" }); + if (req.method === "POST" && p === "/admin/users") { + const b = await read(req); + if (users.has(b.username)) return json(res, 422, { message: "user already exists" }); + if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" }); + users.set(b.username, { admin: b.admin === true, password: b.password, active: true }); + return json(res, 201, { login: b.username, is_admin: b.admin === true }); + } + m = p.match(/^\/admin\/users\/([^/]+)$/); + if (m && req.method === "PATCH") { + const u = users.get(decodeURIComponent(m[1])); + if (!u) return json(res, 404, { message: "user does not exist" }); + const b = await read(req); + if (b.admin !== undefined) u.admin = b.admin; + if (b.active !== undefined) u.active = b.active; + return json(res, 200, { login: m[1], is_admin: u.admin }); + } + if (p === "/orgs/novox/teams") { + if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); + if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name }))); + const b = await read(req); + const t = { id: ++next, name: b.name, units: b, members: new Set() }; + teams.push(t); + if (faults.teamRace) return json(res, 422, { message: "team already exists" }); + return json(res, 201, { id: t.id, name: t.name }); + } + m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/); + if (m) { + if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); + const t = teams.find((x) => x.id === Number(m![1])); + if (!t) return json(res, 404, { message: "team does not exist" }); + if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); } + if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); } + } + // Repository administration: a site administrator, or a team member whose team administers the repository. + m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/); + if (m && req.method === "GET") { + if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main", + enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false }); + if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false }); + return json(res, 404, { message: "branch does not exist" }); + } + // gitea's reqAdmin on the whole group: reading the rules is administration too. + m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/); + if (m) { + const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin"); + if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + return json(res, 201, { rule_name: "main" }); + } + m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/); + if (m && req.method === "POST") { + const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write"); + if (!writes) return json(res, 403, { message: "write on code required" }); + return json(res, 201, { context: (await read(req)).context, creator: { login: me } }); + } + if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] }); + return json(res, 404, { message: "no such route in the fake" }); + }); + return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; + faults: typeof faults; close(): Promise }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults, + close: () => new Promise((r) => server.close(() => r())) }); + }); + }); +} + +const forge = await fakeForge(); +after(() => forge.close()); + +async function environment(): Promise { + const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-")); + const passwordFile = join(dir, "admin.secret"); + await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 }); + return { + MESH_GITEA_URL: forge.url, + MESH_GITEA_ADMIN_USER: ADMIN, + MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile, + MESH_GITEA_STATE_DIR: join(dir, "state"), + }; +} + +/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */ +function fresh(env: NodeJS.ProcessEnv): GiteaClient { + return GiteaClient.fromEnv(env); +} + +test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => { + const env = await environment(); + await fresh(env).listRepos(); + + const account = forge.users.get(TOOLS_ACCOUNT); + assert.ok(account, "the module made the tools' account"); + assert.equal(account!.admin, false, "the tools' account is no administrator"); + const held = forge.tokens.filter((t) => t.name === "mesh-tools"); + assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's"); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n"); + + const team = forge.teams.find((t) => t.name === "mesh-tools"); + assert.ok(team, "the team is made in the mesh's organisation"); + assert.ok(team!.members.has(TOOLS_ACCOUNT)); + assert.equal(team!.units.permission, "write"); + assert.equal(team!.units.includes_all_repositories, true); + assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it"); + assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map); + assert.ok(!Object.values(team!.units.units_map).includes("admin")); +}); + +test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => { + const env = await environment(); + const client = fresh(env); + await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any); + await assert.rejects( + client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }), + /403/, + ); +}); + +test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => { + const env = await environment(); + const client = fresh(env); + await assert.rejects(client.branchProtections("novox", "hq"), /403/); + const rule = await client.appliedProtection("novox", "hq", "main"); + assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]); + assert.equal(rule?.enable_status_check, true); + assert.equal(rule?.rule_name, "main"); + assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null); + assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null); +}); + +test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => { + const env = await environment(); + // What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it. + const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] }; + forge.tokens.push(old); + await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true }); + await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 }); + await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 }); + + await fresh(env).listRepos(); + + assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more"); + const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); + assert.notEqual(kept, old.sha1); + assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT); + assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/); +}); + +test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => { + const env = await environment(); + forge.users.get(TOOLS_ACCOUNT)!.admin = true; + await fresh(env).listRepos(); + assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false); +}); + +test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => { + const env = await environment(); + assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/); +}); + +test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => { + const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); + const code = manifest.build.artifacts.find((a: any) => a.name === "code"); + assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT); + const authors = String(manifest.config["ready-authors"].default).split(/\s+/); + assert.ok(authors.includes(TOOLS_ACCOUNT)); + // Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as + // their own keeps counting as ready after the delivery. + assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting"); + // The Go forge bundle reads the token the tools' account holds, never the administrator's password. + const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge"); + assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE); + assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); +}); + +// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. + +test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => { + const client = fresh(await environment()); + const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) }; + const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any; + // main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see. + assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true }); + // A base nothing guards: not required, so the head's repository check passes rather than fails. + assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false }); +}); + +test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => { + const env = await environment(); + const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] }; + forge.tokens.push(old); + forge.faults.mint = true; + try { + await assert.rejects(fresh(env).listRepos(), /500/); + } finally { + forge.faults.mint = false; + } + assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept"); + forge.tokens.splice(forge.tokens.indexOf(old), 1); +}); + +test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => { + const env = await environment(); + const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] }; + forge.tokens.push(old); + forge.faults.deleteToken = true; + try { + await fresh(env).listRepos(); + } finally { + forge.faults.deleteToken = false; + } + const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); + assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works"); + assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1)); + // The next process reuses the kept token and retires the old one it could not before. + const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!, + file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} }); + assert.equal(await next.current(), kept); + assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start"); +}); + +test("a team another process made between the look and the create is found, not a failure", async () => { + forge.teams.splice(0); + forge.faults.teamRace = true; + try { + await fresh(await environment()).listRepos(); + } finally { + forge.faults.teamRace = false; + } + const team = forge.teams.find((t) => t.name === "mesh-tools"); + assert.ok(team?.members.has(TOOLS_ACCOUNT)); +}); diff --git a/modules/gitea/test/trunk.test.ts b/modules/gitea/test/trunk.test.ts index 7e7fdd4b..3eb373a6 100644 --- a/modules/gitea/test/trunk.test.ts +++ b/modules/gitea/test/trunk.test.ts @@ -173,3 +173,29 @@ test("a verdict that landed out of date keeps its account on the pull request", const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words); assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/); }); + +test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => { + // novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR + // 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release + // branch's move puts its open verdicts out of date, and leaves those into main alone. + const release = "feat/57-config-and-provisioning"; + const heads: Record = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" }; + const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }]; + const { forge, statuses } = aForge(pulls, heads); + const gates = new GateWriter(forge); + const trunks: Record = {}; + await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"); + + heads[release] = MOVED; + assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true); + const s = statuses.get("3".repeat(40))!; + assert.equal(s["mesh/merge-gate"].state, "pending"); + assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/); + assert.equal(s["mesh/repo-check"].state, "pending"); + assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left"); + assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED); + + // The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current. + assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success", + description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false); +}); diff --git a/modules/gitea/test/webhook.test.ts b/modules/gitea/test/webhook.test.ts new file mode 100644 index 00000000..1160de34 --- /dev/null +++ b/modules/gitea/test/webhook.test.ts @@ -0,0 +1,335 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHmac } from "node:crypto"; +import type { AddressInfo } from "node:net"; + +// **The forge says every event on the bus** (novox/hq issue 729): a signed delivery is published once and acted on, +// an unsigned or unverified one is refused, and an event and the poll seeing the same head announce it once. + +const secret = "s".repeat(64); +const sign = (body: string, key = secret) => createHmac("sha256", key).update(body).digest("hex"); +const opened = JSON.stringify({ action: "opened", number: 7, pull_request: { number: 7, title: "t", user: { login: "jo" }, + head: { ref: "feat/x", sha: "a".repeat(40) }, base: { ref: "main" }, merged: false }, + repository: { name: "hq", full_name: "novox/hq", owner: { login: "novox" } }, sender: { login: "jo" } }); + +async function serve(published: { name: string }[], acted: { name: string }[]) { + const { receiver, HOOK_PATH } = await import("../webhook.ts"); + const server = receiver({ secret, port: 0, log: () => {}, + publish: async (e) => { published.push(e); }, act: async (e) => { acted.push(e); } }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`; + return { server, url }; +} + +test("a signed event is published on the bus and acted on once", async () => { + const published: { name: string }[] = [], acted: { name: string }[] = []; + const { server, url } = await serve(published, acted); + try { + const headers = { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-1" }; + const res = await fetch(url, { method: "POST", body: opened, headers }); + assert.equal(res.status, 200); + // The same delivery sent again is answered and published nowhere. + const again = await fetch(url, { method: "POST", body: opened, headers }); + assert.equal(again.status, 200); + await server.idle(); + assert.deepEqual(published.map((e) => e.name), ["forge.pull-request.opened"]); + assert.deepEqual(acted.map((e) => e.name), ["forge.pull-request.opened"]); + } finally { server.close(); } +}); + +test("an unsigned event, or one whose signature does not verify, is refused and published nowhere", async () => { + const published: { name: string }[] = [], acted: { name: string }[] = []; + const { server, url } = await serve(published, acted); + try { + const unsigned = await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request" } }); + assert.equal(unsigned.status, 401); + assert.match(await unsigned.text(), /unsigned/); + const forged = await fetch(url, { method: "POST", body: opened, + headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "another secret") } }); + assert.equal(forged.status, 401); + assert.match(await forged.text(), /does not verify/); + const big = "x".repeat(5 * 1024 * 1024 + 1); + const large = await fetch(url, { method: "POST", body: big, + headers: { "X-Gitea-Event": "push", "X-Gitea-Signature": sign(big) } }); + assert.equal(large.status, 413); + await server.idle(); + assert.deepEqual(published, []); + assert.deepEqual(acted, []); + } finally { server.close(); } +}); + +test("an event and the poll seeing the same head announce it once", async () => { + const { claimHead } = await import("../webhook.ts"); + const heads: Record = {}; + const sha = "a".repeat(40); + assert.equal(claimHead(heads, "novox/hq#7", sha), true, "the event claims the head and announces it"); + assert.equal(claimHead(heads, "novox/hq#7", sha), false, "the poll finds it taken"); + assert.equal(claimHead(heads, "novox/hq#7", "b".repeat(40)), true, "a new head is announced again"); +}); + +test("each forge event is named in the module's stream and trimmed", async () => { + const { eventOf } = await import("../webhook.ts"); + const merged = eventOf("pull_request", { action: "closed", pull_request: { number: 3, merged: true, merge_commit_sha: "c".repeat(40), + head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } }); + assert.equal(merged.name, "forge.pull-request.merged"); + assert.equal(merged.body.merge_commit_sha, "c".repeat(40)); + assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name, + "forge.pull-request.labeled"); + const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 }, + comment: { id: 9, body: "ready at abc", user: { login: "jo" } } }); + assert.equal(comment.name, "forge.pull-request.commented"); + assert.equal(comment.body.comment, "ready at abc"); + assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push"); + assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other"); +}); + +// A forge's admin hooks as 1.27.3 keeps them: POST /admin/hooks makes a system webhook only when config.is_system_webhook +// is "true", and a default webhook otherwise; the listing names one kind at a time (type=system, the default, or default). +// Repositories hold their own webhooks (a default webhook's copies), listed a page at a time. +function fakeForge(start: { id: number; url: string; system: boolean }[] = [], + repoHooks: Record = {}, repoCount = 0) { + const hooks = start.map((h) => ({ ...h, events: ["push"], active: true })); + const repos = [...Object.keys(repoHooks)]; + for (let i = 0; repos.length < repoCount; i++) repos.push(`novox/r${i}`); + let next = 100; + const calls: string[] = []; + const made: any[] = []; + const page = (items: any[], q: URLSearchParams) => { + const p = Number(q.get("page") ?? 1), l = Number(q.get("limit") ?? 50); + return items.slice((p - 1) * l, p * l); + }; + const admin = { call: async (method: string, path: string, body?: any) => { + calls.push(`${method} ${path}`); + const u = new URL(path, "http://f"); + const repoHook = u.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/hooks(?:\/(\d+))?$/); + if (method === "GET" && u.pathname === "/repos/search") { + return { status: 200, body: { ok: true, data: page(repos.map((full_name) => ({ full_name })), u.searchParams) } }; + } + if (repoHook) { + const list = repoHooks[repoHook[1]] ?? []; + if (method === "GET") return { status: 200, body: page(list.map((h) => ({ id: h.id, config: { url: h.url } })), u.searchParams) }; + const i = list.findIndex((h) => h.id === Number(repoHook[2])); + if (i < 0) return { status: 404, body: {} }; + list.splice(i, 1); + return { status: 204, body: null }; + } + if (method === "GET") { + const type = u.searchParams.get("type") ?? "system"; + const listed = hooks.filter((h) => type === "all" || h.system === (type === "system")); + return { status: 200, body: listed.map((h) => ({ id: h.id, active: h.active, events: h.events, + config: { url: h.url, content_type: "json" } })) }; + } + if (method === "POST") { + made.push(body); + hooks.push({ id: next++, url: body.config.url, system: body.config.is_system_webhook === "true", events: body.events, + active: body.active }); + return { status: 201, body: {} }; + } + if (method === "DELETE") { + const id = Number(path.split("/").pop()); + const i = hooks.findIndex((h) => h.id === id); + if (i < 0) return { status: 404, body: {} }; + hooks.splice(i, 1); + return { status: 204, body: null }; + } + return { status: 405, body: {} }; + } }; + return { admin, hooks, calls, made, repoHooks }; +} + +test("the webhook is made a system webhook, which the forge sends every repository's events to", async () => { + const { ensureSystemHook } = await import("../webhook.ts"); + const forge = fakeForge(); + const url = "http://h:3047/forge-event"; + const hook = await ensureSystemHook(forge.admin, url, secret); + assert.equal(forge.made.length, 1); + assert.equal(forge.made[0].config.is_system_webhook, "true", "without it the forge makes a default webhook, which fires for no repository"); + assert.equal(forge.made[0].config.secret, secret); + assert.ok(forge.made[0].events.includes("pull_request") && forge.made[0].events.includes("push")); + assert.deepEqual(forge.hooks.map((h) => h.system), [true]); + assert.equal(hook.kind, "system"); + assert.equal(hook.did, "created"); + assert.equal(hook.url, url); + assert.ok(hook.events.includes("pull_request")); +}); + +test("default webhooks left at the receiver by an earlier start are replaced by one system webhook", async () => { + const { ensureSystemHook } = await import("../webhook.ts"); + const url = "http://h:3047/forge-event"; + // What the start at 23:05 left: a default webhook, invisible to the system listing, made again by every start. + const forge = fakeForge([{ id: 4, url, system: false }, { id: 5, url, system: false }, { id: 6, url: "http://other/x", system: true }]); + const hook = await ensureSystemHook(forge.admin, url, secret); + assert.deepEqual(hook.removed, { system: 0, default: 2, repository: 0, repositories: [] }); + assert.equal(hook.did, "replaced"); + assert.deepEqual(forge.hooks.map((h) => [h.url, h.system]), [["http://other/x", true], [url, true]], "another url is kept"); + // A second start replaces the system webhook, so the secret kept now is the one the forge signs with (its edit never + // changes a secret). + const again = await ensureSystemHook(forge.admin, url, secret); + assert.deepEqual(again.removed, { system: 1, default: 0, repository: 0, repositories: [] }); + assert.equal(forge.hooks.filter((h) => h.url === url).length, 1); + assert.ok(forge.calls.includes("DELETE /admin/hooks/4") && forge.calls.includes("DELETE /admin/hooks/5")); + // The new one is made before any old one is deleted, so no event falls in a gap. + const post = forge.calls.indexOf("POST /admin/hooks"); + assert.ok(post >= 0 && forge.calls.findIndex((c) => c.startsWith("DELETE")) > post); +}); + +test("a repository's copy of a default webhook at the receiver is deleted, on every page, and said", async () => { + const { ensureSystemHook } = await import("../webhook.ts"); + const url = "http://h:3047/forge-event"; + // A repository made after the default webhook got its copy, which would send its events a second time; 120 + // repositories, so the copies sit beyond the first page. + const repoHooks: Record = {}; + for (let i = 0; i < 118; i++) repoHooks[`novox/r${i}`] = []; + repoHooks["novox/late"] = [{ id: 31, url }, { id: 32, url: "http://ci/hook" }]; + repoHooks["novox/later"] = [{ id: 33, url }]; + const forge = fakeForge([{ id: 4, url, system: false }], repoHooks); + const hook = await ensureSystemHook(forge.admin, url, secret); + assert.deepEqual(hook.removed, { system: 0, default: 1, repository: 2, repositories: ["novox/late", "novox/later"] }); + assert.deepEqual(forge.repoHooks["novox/late"].map((h) => h.id), [32], "a repository's other webhook is kept"); + assert.deepEqual(forge.repoHooks["novox/later"], []); + assert.ok(forge.calls.some((c) => /\/repos\/search\?.*page=3/.test(c)), "every page of repositories is read"); +}); + +test("a forge that makes no system webhook is said, never taken as heard", async () => { + const { ensureSystemHook } = await import("../webhook.ts"); + const forge = fakeForge(); + const admin = { call: async (method: string, path: string, body?: any) => { + // A forge that ignores the flag: what 23:05 met, had the module read back. + if (method === "POST") body = { ...body, config: { ...body.config, is_system_webhook: undefined } }; + return forge.admin.call(method, path, body); + } }; + await assert.rejects(ensureSystemHook(admin, "http://h:3047/forge-event", secret), /names 0 new system webhooks/); +}); + +test("the receiver tells what it verified and what it refused", async () => { + const { receiver, HOOK_PATH } = await import("../webhook.ts"); + const heard: string[] = [], refused: string[] = []; + const server = receiver({ secret, port: 0, log: () => {}, publish: async () => {}, + heard: (kind, delivery) => heard.push(`${kind} ${delivery}`), refused: (reason) => refused.push(reason) }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`; + try { + await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-9" } }); + await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "x") } }); + await server.idle(); + assert.deepEqual(heard, ["pull_request d-9"]); + assert.deepEqual(refused, ["its signature does not verify"]); + } finally { server.close(); } +}); + +test("a forge that never delivers is said loudly once, and unhealthy, when the poll finds what no event said", async () => { + const { Pace } = await import("../webhook.ts"); + let now = 1_000_000; + const said: string[] = []; + const pace = new Pace(() => now, (l) => said.push(l)); + const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true, + did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } }; + pace.listening("172.17.0.1", 3047, hook.url, hook); + now += 5 * 60_000; + pace.miss("novox/hq#7 at aaaaaaaa"); + assert.equal(said.length, 0, "five minutes after the start is not yet silence"); + assert.equal((pace.report() as any).healthy, true); + pace.refused("it is unsigned", "172.17.0.3"); + assert.equal((pace.report() as any).healthy, false, "refused, and none verified"); + now += 6 * 60_000; + pace.miss("the merge of novox/hq#7"); + pace.miss("novox/hq#8 at bbbbbbbb"); + assert.equal(said.filter((l) => /NOT HEARD: no delivery ever verified/.test(l)).length, 1, "said once"); + let r = pace.report() as any; + assert.equal(r.healthy, false); + assert.equal(r.state, "unhealthy"); + assert.match(r.why, /never delivered/); + assert.equal(r.polls.found_unsaid, 3); + assert.equal(r.refused.count, 1); + assert.equal(r.refused.last.reason, "it is unsigned"); + assert.equal(r.hook.kind, "system"); + assert.equal(r.receiver.port, 3047); + // The first delivery clears it. + now += 1000; + pace.heard("push", "d-1"); + r = pace.report() as any; + assert.equal(r.healthy, true); + assert.equal(r.verified.count, 1); + assert.equal(r.verified.last.event, "push"); + assert.equal(r.polls.every_seconds, 300); + assert.match(said.join("\n"), /heard again/); +}); + +test("a forge whose events stop after one delivery is said loudly once per silent spell, and refusals are unhealthy", async () => { + const { Pace } = await import("../webhook.ts"); + let now = 1_000_000; + const said: string[] = []; + const pace = new Pace(() => now, (l) => said.push(l)); + const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true, + did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } }; + pace.listening("172.17.0.1", 3047, hook.url, hook); + now += 60_000; + pace.heard("push", "d-1"); + // The hook is deleted by hand: nothing more comes, and the poll finds what no event said. + now += 11 * 60_000; + pace.miss("novox/hq#7 at aaaaaaaa"); + assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 0, "one find alone may be the poll beating an event"); + pace.miss("the merge of novox/hq#7"); + pace.miss("novox/hq#8 at bbbbbbbb"); + assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 1, "said once"); + assert.match(said.find((l) => /NOT HEARD/.test(l)) ?? "", /since the last at/); + assert.equal((pace.report() as any).healthy, false); + // A delivery ends the spell; the next spell is said again. + now += 1000; + pace.heard("push", "d-2"); + assert.equal((pace.report() as any).healthy, true); + now += 11 * 60_000; + pace.miss("novox/hq#9 at cccccccc"); + pace.miss("novox/hq#10 at dddddddd"); + assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 2, "a second silent spell is said again"); + // Every delivery refused after a verified one (a secret that no longer matches) is unhealthy at once. + now += 1000; + pace.heard("push", "d-3"); + now += 1000; + pace.refused("its signature does not verify", "172.17.0.3"); + pace.refused("its signature does not verify", "172.17.0.3"); + const r = pace.report() as any; + assert.equal(r.healthy, false); + assert.equal(r.refused.since_verified, 2); + assert.match(r.why, /2 deliveries refused since the last verified one.*does not verify/); + now += 1000; + pace.heard("push", "d-4"); + assert.equal((pace.report() as any).healthy, true, "a verified delivery clears it"); +}); + +test("without a receiver by configuration the polls alone watch, healthy; a receiver that failed is unhealthy", async () => { + const { Pace } = await import("../webhook.ts"); + let now = 1_000_000; + const said: string[] = []; + const quiet = new Pace(() => now, (l) => said.push(l)); + quiet.unheard("MESH_GITEA_HOOK_URL unset", false); + now += 20 * 60_000; + quiet.miss("novox/hq#7 at aaaaaaaa"); + assert.equal(said.length, 0, "no deliveries were expected"); + assert.equal((quiet.report() as any).healthy, true); + const failed = new Pace(() => now, () => {}); + failed.unheard("the forge's system webhook could not be made: 403"); + const r = failed.report() as any; + assert.equal(r.healthy, false); + assert.match(r.why, /403/); +}); + +test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => { + const { Pace } = await import("../webhook.ts"); + let now = 1_000_000; + const said: string[] = []; + const pace = new Pace(() => now, (l) => said.push(l)); + assert.equal(pace.every(), 30_000, "no delivery yet"); + pace.heard(); + assert.equal(pace.every(), 300_000, "a delivery heard"); + now += 11 * 60_000; + assert.equal(pace.every(), 30_000, "ten minutes without a delivery"); + pace.heard(); + now += 1000; + pace.miss("novox/hq#7 at aaaaaaaa"); + assert.equal(pace.every(), 30_000, "the poll found what no event said"); + assert.match(said.join("\n"), /MISSED novox\/hq#7/); + now += 1000; + pace.heard(); + assert.equal(pace.every(), 300_000, "the next delivery"); +}); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index 3aac30b1..8d7f7afd 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -12,7 +12,20 @@ // hands a token's value out exactly once, so a token not kept is a token lost; // - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not // a fault: the forge's data was restored from a predecessor and the token the file names never -// existed there. +// existed there; +// - minted by one process at a time. The runtime starts each of the bundle's entrypoints as a process +// of its own (novox/hq ADR 0193): the watcher (index.js) and the tools (tools/index.js) are two +// processes over one kept file. Two that minted at once left the forge two tokens by one name, which +// gitea then refuses to delete by name — for ever, and every forge tool with it (the 2026-10-14 +// outage). So a mint holds a lock file beside the kept token, and reads the kept file again once it +// holds it: what another process minted meanwhile is reused, not minted over; +// - replaced by id: an old token by this name is found in the account's list and deleted by its id, +// every one of them, so a name held twice is cleared rather than refused. +// +// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's +// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any +// token by this name the administrator itself still holds — the token the tools used before. A token kept +// for another account than the one asked (the scopes line says whose) is minted again at the next start. // // An explicitly configured token still wins, and is never minted over: if it is rejected, that is // reported, not repaired — somebody chose it. @@ -20,8 +33,10 @@ // The token is never logged. Lines say that one was minted, reused or renewed, and where it is // kept; never what it is. -import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; +import { randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; +import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js"; /** The name the token carries in the forge's own list — one per mesh runtime, found by name. */ export const TOKEN_NAME = "mesh-tools"; @@ -39,7 +54,7 @@ export const TOKEN_NAME = "mesh-tools"; * `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the * operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token. * A token kept with a different list is minted again with this one at the next start (the scopes are kept - * beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this. + * beside it), and the old one is deleted from the forge by its id — so the scope a kept token had does not outlive this. * * A token kept from before a scope was added lacks it: the forge answers such a call with * `403 token does not have at least one of required scope(s)`, and the client treats that like a @@ -95,6 +110,14 @@ export interface MintedTokenOptions { readonly file: string; readonly name?: string; readonly scopes?: readonly string[]; + /** + * The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after + * `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the + * admin's own, as it was before. + */ + readonly account?: string; + /** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */ + readonly setUp?: (password: string) => Promise; readonly log?: (line: string) => void; readonly fetch?: typeof fetch; } @@ -106,12 +129,14 @@ export class MintedToken implements TokenSource { private inflight: Promise | null = null; private readonly name: string; private readonly scopes: readonly string[]; + private readonly account: string; private readonly log: (line: string) => void; private readonly fetchImpl: typeof fetch; constructor(private readonly opts: MintedTokenOptions) { this.name = opts.name ?? TOKEN_NAME; this.scopes = opts.scopes ?? TOKEN_SCOPES; + this.account = opts.account ?? opts.admin; this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`)); this.fetchImpl = opts.fetch ?? fetch; } @@ -124,9 +149,9 @@ export class MintedToken implements TokenSource { */ static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken { const opts = MintedToken.optionsFromEnv(url, env); - // One source per kept file in a process. The watcher and the tools entrypoint both build a - // client in the same runtime; two sources over one file would each renew on a 401 and drop the - // other's token by name, forever. Shared, a renewal is one renewal. + // One source per kept file in a process: two sources in one process would each renew on a 401. + // This covers one process only. The watcher and the tools are two processes (the runtime starts + // each entrypoint as its own), and those are kept apart by the mint's lock file (mint, below). const shared = MintedToken.shared.get(opts.file); if (shared) return shared; const source = new MintedToken(opts); @@ -148,7 +173,17 @@ export class MintedToken implements TokenSource { if (missing.length) { throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`); } - return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") }; + // The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it. + const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT; + if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`); + return { + url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account, + // GiteaAdmin is loaded when first used: client.ts imports this file. + setUp: async (password) => { + const { GiteaAdmin } = await import("./client.js"); + await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url")); + }, + }; } async current(): Promise { @@ -159,11 +194,13 @@ export class MintedToken implements TokenSource { if (kept !== null && this.keptScopes() === this.scopesLine()) { this.held = kept; this.log(`reusing the token kept at ${this.opts.file}`); + // A retirement of the admin's old token that failed after the mint is tried again, once a process. + if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`)); return kept; } - if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them"); + if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept); } - return this.mint("no token kept — minting one"); + return this.mint("no token kept — minting one", null); } async renew(rejected: string): Promise { @@ -177,7 +214,7 @@ export class MintedToken implements TokenSource { return kept; } this.held = null; - return this.mint("the forge rejected the kept token — minting a fresh one"); + return this.mint("the forge rejected the kept token — minting a fresh one", rejected); } /** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */ @@ -185,17 +222,95 @@ export class MintedToken implements TokenSource { return status === 403 && /required scope/i.test(body); } - /** One mint at a time: concurrent first calls share it, rather than each minting its own. */ - private mint(why: string): Promise { + /** + * One mint at a time: concurrent first calls in this process share it, and a mint in another process + * on this machine is waited for (the lock file). Once the lock is held, the kept file is read again: + * a token there that is not `stale` (the one rejected, or kept with other scopes) and carries the + * scopes asked for was minted by the process that held the lock before — it is reused. + */ + private mint(why: string, stale: string | null): Promise { if (this.inflight === null) { this.log(why); - this.inflight = this.doMint().finally(() => { + this.inflight = this.locked(async () => { + const kept = this.read(); + if (kept !== null && kept !== stale && this.keptScopes() === this.scopesLine()) { + this.held = kept; + this.log(`another process minted it meanwhile — reusing the token kept at ${this.opts.file}`); + return kept; + } + return this.doMint(); + }).finally(() => { this.inflight = null; }); } return this.inflight; } + /** How long a mint may hold the lock before another process takes it as left behind, and how long one waits. */ + static readonly LOCK_STALE_MS = 120_000; + static readonly LOCK_WAIT_MS = 150_000; + + /** + * Run `work` holding the lock file beside the kept token: created exclusively, holding this process's + * pid, the time and a nonce; removed after, only when it is still ours. A lock whose process is gone, + * or older than LOCK_STALE_MS, was left by a process that died mid-mint and is taken over — never + * waited on for ever, which would be the same lock-out by another road. + */ + private async locked(work: () => Promise): Promise { + const lock = `${this.opts.file}.lock`; + mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 }); + const mine = `${process.pid} ${Date.now()} ${randomBytes(8).toString("hex")}\n`; + const deadline = Date.now() + MintedToken.LOCK_WAIT_MS; + for (;;) { + try { + const fd = openSync(lock, "wx", 0o600); + try { writeSync(fd, mine); } finally { closeSync(fd); } + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw new Error(`cannot take the mint lock at ${lock}: ${(err as Error).message}`); + } + } + if (this.leftBehind(lock)) { + this.log(`the mint lock at ${lock} was left by a process that is gone — taking it over`); + try { unlinkSync(lock); } catch { /* another waiter took it over first */ } + continue; + } + if (Date.now() > deadline) { + throw new Error(`another process has held the mint lock at ${lock} for over ${MintedToken.LOCK_WAIT_MS / 1000}s — not minting beside it`); + } + await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 50))); + } + try { + return await work(); + } finally { + try { + if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); + } catch { /* already gone */ } + } + } + + /** Whether the lock's holder is gone: its pid answers no signal, or it is older than LOCK_STALE_MS. */ + private leftBehind(lock: string): boolean { + let text: string; + let age: number; + try { + text = readFileSync(lock, "utf8"); + age = Date.now() - statSync(lock).mtimeMs; + } catch { + return false; // released meanwhile: the next attempt takes it + } + if (age > MintedToken.LOCK_STALE_MS) return true; + const pid = Number(text.split(" ")[0]); + if (!Number.isInteger(pid) || pid <= 0) return age > 1_000; // half-written, or not ours: give it a moment + try { + process.kill(pid, 0); + return false; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "ESRCH"; + } + } + private read(): string | null { try { const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, ""); @@ -206,9 +321,10 @@ export class MintedToken implements TokenSource { } } - /** The scopes as kept beside the token: sorted, one line. */ + /** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */ private scopesLine(): string { - return [...this.scopes].sort().join(","); + const scopes = [...this.scopes].sort().join(","); + return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`; } /** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */ @@ -231,17 +347,20 @@ export class MintedToken implements TokenSource { renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`); } - private async doMint(): Promise { - let password: string; + /** One call to the forge's API with the admin login, the password read now. */ + private password(): string { try { - password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, ""); + return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, ""); } catch (err) { throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`); } + } + + private adminCall(password = this.password()): AdminCall { const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64"); - const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`; - const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => { - const res = await this.fetchImpl(tokens + path, { + const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`; + const admin: AdminCall = async (method, path, body) => { + const res = await this.fetchImpl(api + path, { method, headers: { "Content-Type": "application/json", Authorization: authorization }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), @@ -253,30 +372,97 @@ export class MintedToken implements TokenSource { } return { status: res.status, body: parsed }; }; + return admin; + } + + /** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */ + private get retireFile(): string { + return `${this.opts.file}.retire-admin`; + } + + /** + * The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR + * 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without + * a token: it is said, remembered beside the kept token, and tried again when a process next reads that token. + */ + private async retireAdmin(admin: AdminCall = this.adminCall()): Promise { + if (this.account === this.opts.admin) return; + try { + const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin); + if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`); + rmSync(this.retireFile, { force: true }); + } catch (err) { + writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 }); + this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`); + } + } + + private async doMint(): Promise { + const password = this.password(); + const admin = this.adminCall(password); + const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`; + const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body); + + if (this.account !== this.opts.admin) { + // The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again + // at the next call. + const me = await admin("GET", "/user"); + if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status); + await this.opts.setUp?.(password); + } let res = await call("POST", "", { name: this.name, scopes: this.scopes }); if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status); if (res.status === 400 || res.status === 422) { // The forge still holds a token by this name whose value we no longer have — the kept file - // went while the forge's data stayed. It is ours to replace: drop it by name and mint again. - this.log(`the forge already holds a token named "${this.name}" — replacing it`); - const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`); - if (dropped.status !== 204 && dropped.status !== 404) { - throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`); - } + // went while the forge's data stayed, or two processes once minted at once and left two. It is + // ours to replace: find every one by this name in the account's list and delete each by its id + // (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint. + await this.dropNamed(admin, tokensOf(this.account), this.account, true); res = await call("POST", "", { name: this.name, scopes: this.scopes }); } if (res.status !== 201 && res.status !== 200) { - throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`); + throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`); } const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null; - if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`); + if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`); this.keep(token); this.held = token; - this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`); + this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`); + // Only now, with the account's own token kept, does the admin's old one go. + await this.retireAdmin(admin); return token; } + + /** + * Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice: + * "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name + * taken. + */ + private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise { + const held: number[] = []; + for (let page = 1; page <= 100; page++) { + const listed = await admin("GET", `${tokens}?page=${page}&limit=50`); + if (listed.status !== 200 || !Array.isArray(listed.body)) { + throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`); + } + if (listed.body.length === 0) break; + for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id); + } + if (replacing) { + this.log(held.length === 1 + ? `the forge already holds a token named "${this.name}" — replacing it` + : `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`); + } + for (const id of held) { + const dropped = await admin("DELETE", `${tokens}/${id}`); + if (dropped.status !== 204 && dropped.status !== 404) { + throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`); + } + } + return held.length; + } } function detail(body: unknown): string { diff --git a/modules/gitea/tools-account.ts b/modules/gitea/tools-account.ts new file mode 100644 index 00000000..03df8e32 --- /dev/null +++ b/modules/gitea/tools-account.ts @@ -0,0 +1,52 @@ +// The forge's tools act as an account of their own, not as the site administrator (novox/hq ADR 0365 §2, +// issue 469). +// +// The administrator's token reached everything the forge holds, so the tools' boundary rested on their own guards +// (guard.ts): a protection edit, say, the forge itself would have taken. Now the tools' token is minted for a plain +// account, `mesh-tools`, which is no administrator and writes to the mesh's repositories through one team of the +// mesh's organisation: +// +// - **the account**: made by the module with the administrator's login (that login sets the forge up and does +// nothing else), never an administrator — one found made an administrator is made a plain account again. Its +// password is random, sent once and kept nowhere: the account is reached only by the tokens the administrator +// mints for it (gitea lets an administrator mint for another account: `reqSelfOrAdmin`). +// - **the team**: `mesh-tools` in the organisation `novox`, on every repository of it, writing code (pushes to a +// branch, commit statuses, merges), issues (notes, comments, labels) and pull requests. No administration of a +// repository, so the forge refuses the account a protection edit, a repository's settings and its deletion; +// no repository made in the organisation, since its maker would be that repository's administrator. +// +// Every step is idempotent (GiteaAdmin's, client.ts), and run with the administrator's login before each mint of the tools' token, so a team +// or a membership lost behind the module's back is made again at the next mint. + +/** The account the tools act as. */ +export const TOOLS_ACCOUNT = "mesh-tools"; +/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */ +export const TOOLS_ORG = "novox"; +export const TOOLS_TEAM = "mesh-tools"; + +/** What the team grants: write on code, issues and pull requests of every repository of the organisation, and no more. */ +export const TOOLS_TEAM_UNITS = { + permission: "write", + units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" }, + includes_all_repositories: true, + can_create_org_repo: false, +} as const; + +/** One call to the forge's API with the administrator's login: a path under /api/v1, the status and body answered. */ +export type AdminCall = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>; + +/** What setting the account up needs of the forge's admin API: GiteaAdmin's own (client.ts), shared with the + * package provisioner, so a create race (422) and a user made by hand are handled once. */ +export interface ToolsForge { + ensureUser(username: string, password: string, email: string, plain?: boolean): Promise; + ensureTeamWith(org: string, team: string, units: Record): Promise; + addUserToTeam(teamId: number, username: string): Promise; +} + +/** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is + * set afresh each time and kept nowhere: the account is reached only by the tokens the administrator mints. */ +export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOUNT, password: () => string): Promise { + await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true); + const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS }); + await forge.addUserToTeam(team, account); +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 201b08d0..27a8c610 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -10,7 +10,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; -import { GiteaClient, type BranchProtection } from "../client.js"; +import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -59,13 +59,13 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] { })); } -export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { + return namesChecked(allGiteaTools(gitea, admin)); } // gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one // writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { +function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { return [ // ---- Repositories ---- { @@ -401,8 +401,11 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo); - if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) }; - const rule = await gitea.branchProtection(owner, repo, String(args.branch)); + // The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the + // repository administrator's, which the tools' account is not. No other tool uses this account. + const rules = await (admin ? admin() : gitea).branchProtections(owner, repo); + if (!args.branch) return { rules: rules.map(summarised) }; + const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null; return { branch: String(args.branch), rule: rule ? summarised(rule) : null }; }, }, @@ -504,7 +507,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { base = base || "main"; let rule: BranchProtection | null = null, unreadable: string | undefined; try { - rule = await gitea.branchProtection(owner, repo, base); + rule = await gitea.appliedProtection(owner, repo, base); } catch (err) { unreadable = err instanceof Error ? err.message : String(err); } @@ -514,9 +517,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, { name: "gitea_open_pulls", - description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.", + description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at \" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.", input: {}, - run: async () => ({ pulls: await gitea.openPulls() }), + run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }), }, { name: "gitea_contains", @@ -589,8 +592,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { const { owner, repo, branch, newBranch } = judged.contents; const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`); const defaultBranch = String(found?.default_branch ?? "main"); - const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch, - await gitea.branchProtections(owner, repo)); + // The protection the forge applies to the branch written, read from the branch: the rules' own list is a + // repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is + // left to the forge, which refuses a write that would make a branch a rule guards. + const target = newBranch ?? branch ?? defaultBranch; + const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target); + const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []); if (refused) throw new Refused(refused); } // The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts). @@ -611,7 +618,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { // way, gitea contributes none rather than failing the whole runtime, and says why. registerModuleTools("gitea", (env) => { try { - return getGiteaTools(GiteaClient.fromEnv(env)); + return getGiteaTools(GiteaClient.fromEnv(env), () => GiteaAdmin.fromEnv(env)); } catch (err) { console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`); return []; diff --git a/modules/gitea/trunk.ts b/modules/gitea/trunk.ts index 1f0b1a6a..1555fab1 100644 --- a/modules/gitea/trunk.ts +++ b/modules/gitea/trunk.ts @@ -128,10 +128,10 @@ export interface OutOfDateWriter { * out of date (ADR 0323 §4). A trunk first seen is recorded as it is and nothing is put out of date. Bounded however * many are open: one listing of the open pull requests (paged), one read of each trunk, then per open pull request into * a trunk that moved one read and at most two writes, one at a time. A trunk that could not be weighed whole is not - * recorded, so the next pass weighs it again. Answers whether the record changed. + * recorded, so the next pass weighs it again. Answers whether the record changed. `moved` is told of each base that moved, for the siblings whose base it is. */ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, trunks: Record, owner: string, - name: string): Promise { + name: string, moved?: (base: string) => Promise): Promise { let changed = false; try { const open = (await forge.listOpenPulls(owner, name)).filter((p) => p.state === "open" && !!p.head_sha && !!p.base); @@ -151,6 +151,8 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru } console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` + `request(s) into it put out of date, nothing run (novox/hq ADR 0323)`); + // A verdict that read a pull request into this trunk as a sibling is out of date by its move too (ADR 0382 §5). + if (moved) await moved(base); } trunks[key] = head; changed = true; diff --git a/modules/gitea/webhook.ts b/modules/gitea/webhook.ts new file mode 100644 index 00000000..7cb63671 --- /dev/null +++ b/modules/gitea/webhook.ts @@ -0,0 +1,519 @@ +// **The forge says every event on the bus** (novox/hq issue 729; to-be 10: an event makes it fast, nothing makes it +// necessary). The module keeps one system webhook on its forge for every event, pointing at its own receiver, signed +// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and +// publishes each event, trimmed, on the module's events as forge.. (forge.pull-request.opened, forge.push, …), +// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships. +// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from +// being announced twice, whichever path saw it first. +// +// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is. + +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { dirname } from "node:path"; + +/** The name the receiver answers on, and the port it listens on unless the manifest says otherwise. */ +export const HOOK_PATH = "/forge-event"; +export const HOOK_PORT = 3047; + +/** The secret the forge signs with: read from its 0600 file, or minted and kept there. */ +export function keptSecret(file: string, log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)): string { + try { + const kept = readFileSync(file, "utf8").trim(); + if (kept.length >= 32) { + log(`reusing the webhook secret kept at ${file}`); + return kept; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + const secret = randomBytes(32).toString("hex"); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp`; + writeFileSync(tmp, secret + "\n", { mode: 0o600 }); + chmodSync(tmp, 0o600); + renameSync(tmp, file); + log(`minted a webhook secret, kept at ${file}`); + return secret; +} + +/** Whether a body carries the forge's HMAC-SHA256 signature (X-Gitea-Signature, hex) under the secret. */ +export function verified(secret: string, body: Buffer | string, signature: string | undefined): boolean { + if (!secret || !signature || !/^[0-9a-f]{64}$/i.test(signature.trim())) return false; + const want = createHmac("sha256", secret).update(body).digest(); + const got = Buffer.from(signature.trim(), "hex"); + return got.length === want.length && timingSafeEqual(got, want); +} + +/** One forge event as the bus carries it: its name inside the stream, and what is needed of it — no secrets. */ +export interface ForgeEvent { + name: string; + body: Record; +} + +/** Every event name the module emits for the forge, without its forge. prefix (module.json `emits`). */ +export const EVENTS: readonly string[] = ["push", "create", "delete", "fork", "status", "pull-request.opened", "pull-request.reopened", "pull-request.synchronized", "pull-request.edited", "pull-request.closed", "pull-request.merged", "pull-request.labeled", "pull-request.unlabeled", "pull-request.assigned", "pull-request.unassigned", "pull-request.reviewed", "pull-request.review-requested", "pull-request.commented", "pull-request.changed", "issue-comment.created", "issue-comment.edited", "issue-comment.deleted", "issues.opened", "issues.reopened", "issues.closed", "issues.edited", "issues.labeled", "issues.unlabeled", "issues.assigned", "issues.unassigned", "issues.changed", "release.published", "release.updated", "release.deleted", "release.changed", "repository.created", "repository.deleted", "repository.changed", "wiki.changed", "other"]; + +/** The families the module's stream carries (module.json `streams`); any other kind is said as other.; underscores become hyphens. */ +export const FAMILIES = ["push", "create", "delete", "fork", "status", "pull_request", "issue_comment", "issues", + "release", "repository", "wiki"] as const; + +const ACTIONS: Record = { label_updated: "labeled", label_cleared: "unlabeled", synchronized: "synchronized" }; + +function str(v: unknown): string | undefined { + return typeof v === "string" && v ? v : undefined; +} +function num(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined; +} +function login(u: any): string | undefined { + return str(u?.login) ?? str(u?.username); +} + +/** + * The event a forge delivery is: its kind (X-Gitea-Event) and the payload's action, trimmed to ids, repository, refs, + * shas, author, labels and a comment's body (a ready mark is one, ADR 0364). A merge is pull-request.merged, a comment on + * a pull request pull-request.commented. + */ +export function eventOf(kind: string, payload: any): ForgeEvent { + // The bus's event names take lower-case letters, digits and hyphens: the forge's underscores become hyphens. + const event = forgeEventOf(kind, payload); + const name = event.name.toLowerCase().replace(/_/g, "-").replace(/[^a-z0-9.-]/g, "-"); + // Published on the module's events as it publishes its others, each name declared in its `emits`: one the manifest + // does not name is forge.other, saying what the forge called it. Its own stream (ADR 0363) follows issue 725. + if (EVENTS.includes(name)) return { name: `forge.${name}`, body: event.body }; + return { name: "forge.other", body: { ...event.body, kind, said: name } }; +} + +function forgeEventOf(said: string, payload: any): ForgeEvent { + // The forge names some kinds finer than their family (pull_request_label, pull_request_sync): read by the payload. + let kind = said; + if (said.startsWith("pull_request") && payload?.pull_request) kind = "pull_request"; + if ((said.startsWith("issue") || said === "pull_request_comment") && payload?.comment) kind = "issue_comment"; + if (said.startsWith("issue") && !payload?.comment && payload?.issue) kind = "issues"; + const repo = payload?.repository ?? {}; + const base: Record = { + owner: login(repo.owner), repo: str(repo.name), full_name: str(repo.full_name), clone_url: str(repo.clone_url), + sender: login(payload?.sender), + }; + const family = (FAMILIES as readonly string[]).includes(kind) ? kind : `other`; + let action = str(payload?.action); + const pull = payload?.pull_request; + if (kind === "push") { + return { name: "push", body: { ...base, ref: str(payload.ref), before: str(payload.before), after: str(payload.after), + commits: Array.isArray(payload.commits) ? payload.commits.length : undefined, pusher: login(payload.pusher) } }; + } + if (kind === "pull_request" && pull) { + if (action === "closed" && pull.merged) action = "merged"; + action = ACTIONS[action ?? ""] ?? action ?? "changed"; + return { name: `pull_request.${action}`, body: { ...base, number: num(pull.number ?? payload.number), + title: str(pull.title), author: login(pull.user), head: str(pull.head?.ref), head_sha: str(pull.head?.sha), + base: str(pull.base?.ref), merged: !!pull.merged, merge_commit_sha: str(pull.merge_commit_sha), + merged_at: str(pull.merged_at), html_url: str(pull.html_url), + labels: Array.isArray(pull.labels) ? pull.labels.map((l: any) => str(l?.name)).filter(Boolean) : undefined, + label: str(payload.label?.name) } }; + } + if (kind === "issue_comment") { + const issue = payload.issue ?? {}; + const onPull = !!payload.is_pull || !!issue.pull_request; + return { name: onPull ? "pull_request.commented" : `issue_comment.${action ?? "created"}`, body: { ...base, + number: num(issue.number), comment_id: num(payload.comment?.id), author: login(payload.comment?.user), + comment: str(payload.comment?.body), action } }; + } + if (kind === "create" || kind === "delete") { + return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } }; + } + if (kind === "status") { + return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } }; + } + const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` : + ["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`; + return { name, body: { ...base, number: num(payload?.number ?? payload?.issue?.number), action } }; +} + +export interface ReceiverOptions { + secret: string; + port: number; + host?: string; + /** Publishes one event on the bus. */ + publish: (event: ForgeEvent) => Promise; + /** Acts on it at once, beside publishing it (the announcer). */ + act?: (event: ForgeEvent) => Promise; + /** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are + * heard, and the module can say when it last heard one. */ + heard?: (kind: string, delivery: string) => void; + /** Told of every refused delivery, with the reason and where it came from. */ + refused?: (reason: string, from: string) => void; + log?: (line: string) => void; +} + +/** The most a delivery may carry: the forge's push of many commits stays well under it. */ +export const MAX_BODY = 5 * 1024 * 1024; +/** How many deliveries' ids are remembered, so a delivery sent again is published once. */ +export const REMEMBERED = 2000; + +/** + * The receiver. A request without a signature is refused before its body is read, a body over MAX_BODY is refused, and + * one whose signature does not verify is refused, each with 401 or 413 and a line saying so. A verified delivery is + * answered 200 at once and queued: it is published and acted on after the reply, one at a time, so the forge's + * delivery timeout is never reached. A delivery whose X-Gitea-Delivery id was already heard is answered and dropped. + */ +export function receiver(opts: ReceiverOptions): Server & { idle(): Promise } { + const log = opts.log ?? ((l) => console.log(`[gitea] ${l}`)); + const refuse = (from: string, reason: string): void => { + log(`refused a forge event from ${from}: ${reason}`); + opts.refused?.(reason, from); + }; + const seen = new Set(); + let queue: Promise = Promise.resolve(); + const server = createServer((req, res) => { + if (req.method !== "POST" || (req.url ?? "").split("?")[0] !== HOOK_PATH) { + res.statusCode = 404; + return res.end(); + } + const from = req.socket.remoteAddress ?? "?"; + const signature = String(req.headers["x-gitea-signature"] ?? "").trim(); + if (!signature) { + refuse(from, "it is unsigned"); + res.writeHead(401, { Connection: "close" }); + res.end("unsigned\n"); + req.destroy(); + return; + } + if (Number(req.headers["content-length"] ?? 0) > MAX_BODY) { + refuse(from, `its body is over ${MAX_BODY} bytes`); + res.writeHead(413, { Connection: "close" }); + res.end("too large\n"); + req.destroy(); + return; + } + const chunks: Buffer[] = []; + let size = 0; + let over = false; + req.on("data", (c: Buffer) => { + size += c.length; + if (size > MAX_BODY) { + if (!over) { + over = true; + refuse(from, `its body is over ${MAX_BODY} bytes`); + res.writeHead(413, { Connection: "close" }); + res.end("too large\n"); + req.destroy(); + } + } else chunks.push(c); + }); + req.on("end", () => { + if (over) return; + const body = Buffer.concat(chunks); + if (!verified(opts.secret, body, signature)) { + refuse(from, "its signature does not verify"); + res.statusCode = 401; + return res.end("signature does not verify\n"); + } + let payload: unknown; + try { + payload = JSON.parse(body.toString("utf8")); + } catch { + refuse(from, "it is signed but not JSON"); + res.statusCode = 400; + return res.end("not JSON\n"); + } + const delivery = String(req.headers["x-gitea-delivery"] ?? ""); + const kind = String(req.headers["x-gitea-event"] ?? "other"); + opts.heard?.(kind, delivery); + if (delivery && seen.has(delivery)) { + res.statusCode = 200; + return res.end("already heard\n"); + } + if (delivery) { + seen.add(delivery); + if (seen.size > REMEMBERED) seen.delete(seen.values().next().value as string); + } + const event = eventOf(kind, payload); + res.statusCode = 200; + res.end("queued\n"); + queue = queue.then(async () => { + try { + await opts.publish(event); + if (opts.act) await opts.act(event); + } catch (err) { + log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`); + } + }); + }); + }); + return Object.assign(server, { idle: () => queue }); +} + +/** What ensuring the system webhook needs of the forge's administration (GiteaAdmin's login). */ +export interface HookAdmin { + call(method: string, path: string, body?: unknown): Promise<{ status: number; body: any }>; +} + +/** The forge's one system webhook as ensured at start: read back from the forge after it was made. */ +export interface HookState { + /** Always "system": a hook the forge's listing of system webhooks names, so it fires for every repository. */ + kind: "system"; + id: number; + url: string; + events: string[]; + active: boolean; + content_type?: string; + /** Made where none was, or made in place of the ones there (system, default, or a repository's) at the receiver's url. */ + did: "created" | "replaced"; + /** The hooks at the receiver's url it took the place of, by kind: a repository's is a copy of a default webhook, which + * would have sent that repository's events a second time. */ + removed: { system: number; default: number; repository: number; repositories: string[] }; +} + +/** How long without a verified delivery — since the start, or since the last one — a forge is said silent once the poll + * finds what no event said. */ +export const SILENT_AFTER = 10 * 60_000; + +/** + * **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the + * forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first + * delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds. + * + * It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused + * deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver + * listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery — + * and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an + * event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a + * delivery verifies. Deliveries refused since the last verified one are said unhealthy too. + */ +export class Pace { + private last = 0; + private missed = 0; + private readonly started: number; + private readonly now: () => number; + private readonly log: (l: string) => void; + private where: { host: string; port: number; url: string } | null = null; + private hook: HookState | null = null; + private notHeard: string | null = null; + private notHeardFails = false; + private verifiedCount = 0; + private lastVerified: { at: number; event: string; delivery: string } | null = null; + private refusedCount = 0; + private refusedSpell = 0; + private lastRefused: { at: number; reason: string; from: string } | null = null; + private missCount = 0; + private lastMiss: { at: number; what: string } | null = null; + private silentSince = 0; + private quietMisses = 0; + constructor(now: () => number = Date.now, log: (l: string) => void = (l) => console.error(`[gitea] ${l}`)) { + this.now = now; + this.log = log; + this.started = now(); + } + /** The receiver listens, and the hook at it was ensured: from now on, deliveries are expected. */ + listening(host: string, port: number, url: string, hook: HookState): void { + this.where = { host, port, url }; + this.hook = hook; + this.notHeard = null; + } + /** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen + * or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */ + unheard(why: string, fails = true): void { + this.notHeard = why; + this.notHeardFails = fails; + } + heard(event = "", delivery = ""): void { + this.last = this.now(); + this.verifiedCount++; + this.lastVerified = { at: this.last, event, delivery }; + this.refusedSpell = 0; + this.quietMisses = 0; + if (this.silentSince) this.log(`the forge's events are heard again: ${event || "a delivery"} verified`); + this.silentSince = 0; + } + refused(reason: string, from: string): void { + this.refusedCount++; + this.refusedSpell++; + this.lastRefused = { at: this.now(), reason, from }; + } + /** The poll found a head or merge no forge event announced. */ + miss(what: string): void { + const t = this.now(); + if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`); + this.missed = t; + this.missCount++; + this.lastMiss = { at: t, what }; + // Silent: nothing verified for SILENT_AFTER — never since the start, or not since the last delivery (a hook deleted by + // hand, every delivery refused) — while the poll finds what no event said. Said once per silent spell. + const quietSince = this.last || this.started; + this.quietMisses++; + if (this.expecting() && t - quietSince >= SILENT_AFTER && this.quietMisses >= 2 && !this.silentSince) { + this.silentSince = t; + const minutes = Math.round((t - quietSince) / 60_000); + const refusedSince = this.refusedSinceHeard(); + this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` + + `${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` + + `system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` + + `${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` + + `(novox/hq issue 729)`); + } + } + /** Whether a delivery was refused after the last verified one (or with none verified). */ + private refusedSinceHeard(): number { + return this.lastRefused && this.lastRefused.at >= this.last ? this.refusedSpell : 0; + } + hearing(): boolean { + const t = this.now(); + return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed; + } + every(): number { + return this.hearing() ? 5 * 60_000 : 30_000; + } + private expecting(): boolean { + return this.where !== null && this.hook !== null; + } + /** What `gitea_events` answers: healthy, unhealthy or waits (ADR 0283's shape), with every fact behind it. */ + report(): Record { + const t = this.now(); + const iso = (ms: number): string => new Date(ms).toISOString(); + let state: "healthy" | "unhealthy" | "waits" = "healthy"; + let why: string; + const refusedSince = this.refusedSinceHeard(); + if (this.silentSince) { + state = "unhealthy"; + why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` + + `event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` + + `event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`; + } else if (refusedSince) { + state = "unhealthy"; + why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` : + "and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`; + } else if (!this.expecting()) { + why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` : + "the receiver is not listening yet"; + if (this.notHeard && this.notHeardFails) state = "unhealthy"; + } else if (this.hearing()) { + why = `the forge's events are heard: ${this.verifiedCount} verified, the last at ${iso(this.last)}`; + } else if (this.last === 0) { + why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`; + } else { + why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`; + } + return { + healthy: state !== "unhealthy", state, why, + started: iso(this.started), + receiver: this.where ? { listening: true, ...this.where } : { listening: false, why: this.notHeard ?? "not started" }, + hook: this.hook, + verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } }, + refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } }, + polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount, + last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } }, + silent: this.silentSince ? { since: iso(this.silentSince) } : null, + }; + } +} + +/** Every event family the forge (1.27) can send a webhook. */ +export const HOOK_EVENTS = ["create", "delete", "fork", "push", "issues", "issue_comment", "pull_request", + "pull_request_review_request", "wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job"]; + +async function hooksAt(admin: HookAdmin, type: "system" | "default", url: string): Promise { + const listed = await admin.call("GET", `/admin/hooks?type=${type}&limit=50`); + if (listed.status !== 200) throw new Error(`the forge's ${type} webhooks could not be listed: ${listed.status}`); + return (Array.isArray(listed.body) ? listed.body : []).filter((h) => h?.config?.url === url); +} + +/** Every page of a listing that answers an array (or `{ data: [] }`, the repository search), PAGE at a time. */ +const PAGE = 50; +async function everyPage(admin: HookAdmin, path: string, what: string): Promise { + const all: any[] = []; + for (let page = 1; ; page++) { + const r = await admin.call("GET", `${path}${path.includes("?") ? "&" : "?"}page=${page}&limit=${PAGE}`); + if (r.status !== 200) throw new Error(`${what} could not be listed: ${r.status}`); + const items: any[] = Array.isArray(r.body) ? r.body : Array.isArray(r.body?.data) ? r.body.data : []; + all.push(...items); + if (items.length < PAGE) return all; + } +} + +/** Every repository's own webhooks at the receiver's url: copies of a default webhook made at it. */ +async function repoHooksAt(admin: HookAdmin, url: string): Promise<{ repo: string; id: number }[]> { + const found: { repo: string; id: number }[] = []; + for (const repo of await everyPage(admin, "/repos/search?sort=id", "the forge's repositories")) { + const full = String(repo?.full_name ?? ""); + if (!full.includes("/")) continue; + const hooks = await everyPage(admin, `/repos/${full}/hooks`, `${full}'s webhooks`); + for (const h of hooks) if (h?.config?.url === url) found.push({ repo: full, id: Number(h.id) }); + } + return found; +} + +/** + * **One system webhook, for every event, at the receiver** (issue 729), made each start and read back. + * + * The forge (1.27.3, routers/api/v1/utils/hook.go addHook) makes a *default* webhook from POST /admin/hooks unless + * `config.is_system_webhook` is "true": a default webhook fires for no repository and is only copied into repositories + * made after it, and the listing of system webhooks does not name it — so without the flag every start made another + * one and no event came. Its edit (editHook) never changes a webhook's secret nor its kind, so a hook already there is + * not corrected but replaced: the new system webhook is made first, with the secret kept now, so no event falls in a + * gap; then every other hook at the receiver's url is deleted — system, default, and each repository's copy of a + * default one, which would send that repository's events twice. The forge's listing of system webhooks must then name + * exactly one at the url, or this fails, saying so. + */ +export async function ensureSystemHook(admin: HookAdmin, url: string, secret: string): Promise { + // The forge has no "every event" word: each family is named, and a family name (issues, pull_request) takes all of it. + const want = { type: "gitea", active: true, events: HOOK_EVENTS, branch_filter: "*", + config: { url, content_type: "json", secret, is_system_webhook: "true" } }; + const before = new Set((await hooksAt(admin, "system", url)).map((h) => Number(h.id))); + const made = await admin.call("POST", "/admin/hooks", want); + if (made.status !== 201 && made.status !== 200) throw new Error(`the forge's system webhook could not be made: ${made.status}`); + const fresh = (await hooksAt(admin, "system", url)).filter((h) => !before.has(Number(h.id))); + if (fresh.length !== 1) { + throw new Error(`the forge names ${fresh.length} new system webhooks at ${url} after one was made, not one: its events ` + + `are not known to be sent`); + } + const ours = fresh[0]; + const defaults = await hooksAt(admin, "default", url); + const gone = async (path: string): Promise => { + const r = await admin.call("DELETE", path); + if (r.status !== 204 && r.status !== 200 && r.status !== 404) throw new Error(`the forge's webhook ${path} could not be deleted: ${r.status}`); + }; + for (const id of before) await gone(`/admin/hooks/${id}`); + for (const h of defaults) await gone(`/admin/hooks/${h.id}`); + const copies = await repoHooksAt(admin, url); + for (const c of copies) await gone(`/repos/${c.repo}/hooks/${c.id}`); + const kept = await hooksAt(admin, "system", url); + if (kept.length !== 1 || Number(kept[0].id) !== Number(ours.id)) { + throw new Error(`the forge names ${kept.length} system webhooks at ${url} once the others were deleted, not the one ` + + `made (${ours.id})`); + } + const removed = { system: before.size, default: defaults.length, repository: copies.length, + repositories: [...new Set(copies.map((c) => c.repo))] }; + return { kind: "system", id: Number(ours.id), url, events: Array.isArray(ours.events) ? ours.events : [], + active: !!ours.active, content_type: ours.config?.content_type, + did: removed.system + removed.default + removed.repository > 0 ? "replaced" : "created", removed }; +} + +/** The `gitea_events` tool: what the module knows of the forge's events, from its Pace. */ +export function eventsTool(pace: Pace): { name: string; description: string; input: Record; + run: (args: Readonly>) => Promise } { + return { + name: "gitea_events", + description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " + + "system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " + + "verified and the last one's time and event, how many were refused and the last reason, how often the polls look " + + "and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " + + "forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " + + "poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " + + "could not listen or the webhook could not be made.", + input: {}, + run: async () => pace.report(), + }; +} + +/** + * **Claim a head for announcing, once** (issue 729): true when `sha` is not already the head recorded for `key`, which + * it then is. The poll and the forge's event both claim before they announce, so whichever comes second finds it taken. + */ +export function claimHead(heads: Record, key: string, sha: string): boolean { + if (!sha || heads[key] === sha) return false; + heads[key] = sha; + return true; +} diff --git a/modules/gitlab/files/config.json b/modules/gitlab/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/modules/gitlab/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/gitlab/module.json b/modules/gitlab/module.json index a925e1bb..38be2014 100644 --- a/modules/gitlab/module.json +++ b/modules/gitlab/module.json @@ -4,6 +4,14 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "gitlab-url": { + "type": "string", + "description": "The address of the GitLab instance the gitlab tools call, as a URL such as https://gitlab.com, laid into the file as GITLAB_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", @@ -16,8 +24,14 @@ "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "gitlab-url", + "as": "GITLAB_URL" + } + ] } ], "capabilities": [ @@ -39,6 +53,11 @@ "MESH_GITLAB_TOKEN_FILE": "${dir:state}/token", "MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go index d4fb76d9..fb992951 100644 --- a/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go @@ -14,6 +14,8 @@ import ( type manifest struct { Module string `json:"module"` Version string `json:"version"` + Moved bool `json:"moved"` + Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` @@ -44,6 +46,53 @@ type shellCode struct { Code string `json:"code"` } +// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} + +// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of +// the collections shell::, in the order they are declared. +func (m manifest) shell(t *testing.T) []shellCode { + t.Helper() + var out []shellCode + for _, it := range m.Items { + parts := strings.Split(it.Collection, ":") + if len(parts) != 3 || parts[0] != "shell" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) + } + return out +} + +// environmentOf is the variables the module sets, read from the templates of its items of the environment. +func (m manifest) environmentOf(t *testing.T) *environment { + t.Helper() + var env *environment + for _, it := range m.Items { + if it.Collection != "environment" || it.Attributes["kind"] != "variable" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + if env == nil { + env = &environment{Variables: map[string]string{}} + } + env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + return env +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -56,6 +105,7 @@ func readManifest(t *testing.T) manifest { if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } + m.Environment = m.environmentOf(t) return m } @@ -83,26 +133,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go index 67754e06..25f7b3f6 100644 --- a/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go @@ -29,13 +29,13 @@ func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) { "pam-login": {"/etc/pam.d/login", "files/pam/login"}, "pam-passwd": {"/etc/pam.d/passwd", "files/pam/passwd"}, } { - m.sameAsSource(t, id, want.source) + m.fromTemplate(t, id, want.source) r := m.resource(t, id) if r["path"] != want.path || r["into"] != "block" || r["at"] != "end" || r["owner"] != nil { t.Errorf("%s: %v", id, r) } } - login := m.resource(t, "pam-login")["content"].(string) + login := m.text(t, "pam-login") if !strings.Contains(login, "\nauth optional pam_gnome_keyring.so\n") || !strings.Contains(login, "\nsession optional pam_gnome_keyring.so auto_start\n") { t.Fatalf("%s", login) @@ -44,10 +44,10 @@ func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) { func TestItStartsNoDaemonAndOnlyNamesTheAgentsSocket(t *testing.T) { m := readManifest(t) - if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "first" { - t.Fatalf("%+v", m.Shell) + if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "first" { + t.Fatalf("%+v", m.shell(t)) } - code := m.Shell[0].Code + code := m.shell(t)[0].Code if strings.Contains(code, "gnome-keyring-daemon") || strings.Contains(code, "--unlock") || !strings.Contains(code, `SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"`) { t.Fatalf("%q", code) @@ -62,3 +62,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} diff --git a/modules/gnome-keyring/files/xinitrc-first b/modules/gnome-keyring/files/xinitrc-first new file mode 100644 index 00000000..5fb92b81 --- /dev/null +++ b/modules/gnome-keyring/files/xinitrc-first @@ -0,0 +1,5 @@ +# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager +# starts on first use from its socket. Named here, for the session and every terminal it starts, until +# the account's environment can say a path under the runtime directory (see the module's README). +SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh" +export SSH_AUTH_SOCK diff --git a/modules/gnome-keyring/module.json b/modules/gnome-keyring/module.json index 0c796fc2..9f75b9ff 100644 --- a/modules/gnome-keyring/module.json +++ b/modules/gnome-keyring/module.json @@ -1,6 +1,7 @@ { "module": "gnome-keyring", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -16,11 +17,11 @@ "gnome_keyring_collections", "gnome_keyring_ssh_keys" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "first", - "code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n" + "collection": "shell:xinitrc:first", + "name": "xinitrc-first", + "template": "files/xinitrc-first" } ], "resources": [ @@ -46,7 +47,7 @@ "mode": "0644", "into": "block", "at": "end", - "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n" + "template": "files/pam/login" }, { "id": "pam-passwd", @@ -55,7 +56,7 @@ "mode": "0644", "into": "block", "at": "end", - "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n" + "template": "files/pam/passwd" } ], "build": { @@ -70,6 +71,11 @@ "loads": [ "gnome-keyring-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/grafana/files/admin.secret b/modules/grafana/files/admin.secret new file mode 100644 index 00000000..51bf424b --- /dev/null +++ b/modules/grafana/files/admin.secret @@ -0,0 +1 @@ +${secret:admin} \ No newline at end of file diff --git a/modules/grafana/files/config.json b/modules/grafana/files/config.json new file mode 100644 index 00000000..567df9a7 --- /dev/null +++ b/modules/grafana/files/config.json @@ -0,0 +1,4 @@ +{ + "user": "admin", + "password": "${secret:admin}" +} diff --git a/modules/grafana/files/influxdb-api b/modules/grafana/files/influxdb-api new file mode 100644 index 00000000..8329ab78 --- /dev/null +++ b/modules/grafana/files/influxdb-api @@ -0,0 +1 @@ +${secret:influxdb-api} \ No newline at end of file diff --git a/modules/grafana/files/mesh-influxdb.yaml b/modules/grafana/files/mesh-influxdb.yaml new file mode 100644 index 00000000..e3410057 --- /dev/null +++ b/modules/grafana/files/mesh-influxdb.yaml @@ -0,0 +1,19 @@ +apiVersion: 1 +# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330): +# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and +# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because +# the mesh resets it. The password is read from the file the mesh delivers, never written here. +datasources: + - name: InfluxDB (mesh) + uid: mesh-influxdb-api + type: influxdb + access: proxy + url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port} + user: ${bound:influxdb-api:as} + isDefault: false + editable: false + jsonData: + dbName: ${bound:influxdb-api:bucket} + httpMode: POST + secureJsonData: + password: $__file{/run/secrets/influxdb/influxdb-api} diff --git a/modules/grafana/files/oidc-client.secret b/modules/grafana/files/oidc-client.secret new file mode 100644 index 00000000..9146dc18 --- /dev/null +++ b/modules/grafana/files/oidc-client.secret @@ -0,0 +1 @@ +${secret:oidc-client} \ No newline at end of file diff --git a/modules/grafana/files/oidc.env b/modules/grafana/files/oidc.env new file mode 100644 index 00000000..bcc1e9c2 --- /dev/null +++ b/modules/grafana/files/oidc.env @@ -0,0 +1,13 @@ +GF_SERVER_ROOT_URL=https://${bound:route:name} +GF_AUTH_GENERIC_OAUTH_ENABLED=true +GF_AUTH_GENERIC_OAUTH_NAME=Keycloak +GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as} +GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client +GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles +GF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path} +GF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path} +GF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path} +GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer' +GF_AUTH_GENERIC_OAUTH_USE_PKCE=true +GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true +GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 8c16177f..9d8ceebf 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -29,6 +29,20 @@ } ] }, + "config": { + "user": { + "type": "string", + "description": "The Grafana account the module's tools sign in as with the vault's password; the file's default is admin.", + "trusted": true, + "default": "admin" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Grafana at, as a URL; unset, the code falls back to its environment, else this machine's Grafana on its port.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -54,7 +68,7 @@ "path": "${dir:state}/admin.secret", "mode": "0400", "owner": "472:472", - "content": "${secret:admin}" + "template": "files/admin.secret" }, { "id": "oidc-secret", @@ -62,14 +76,14 @@ "path": "${dir:state}/oidc-client.secret", "mode": "0400", "owner": "472:472", - "content": "${secret:oidc-client}" + "template": "files/oidc-client.secret" }, { "id": "oidc-env", "type": "file", "path": "${dir:state}/oidc.env", "mode": "0644", - "content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n" + "template": "files/oidc.env" }, { "id": "influxdb-datasources", @@ -91,7 +105,7 @@ "path": "${dir:influxdb-credential}/influxdb-api", "mode": "0400", "owner": "472:472", - "content": "${secret:influxdb-api}", + "template": "files/influxdb-api", "only-when-bound": "influxdb-api" }, { @@ -99,7 +113,7 @@ "type": "file", "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644", - "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "template": "files/mesh-influxdb.yaml", "only-when-bound": "influxdb-api" }, { @@ -140,8 +154,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n", + "template": "files/config.json", "merge": "json", + "takes-config": ["user", "url"], "protected": ["password", "token"] } ], @@ -194,6 +209,11 @@ "MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}", "MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/grafana/test/manifest.test.ts b/modules/grafana/test/manifest.test.ts index b0516070..a6d362ce 100644 --- a/modules/grafana/test/manifest.test.ts +++ b/modules/grafana/test/manifest.test.ts @@ -7,11 +7,26 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; type Requirement = string | { name: string; optional?: boolean }; -type Resource = { id: string; type: string; path?: string; content?: string; volumes?: string[]; "only-when-bound"?: string }; +type Resource = { id: string; type: string; path?: string; content?: string; template?: string; volumes?: string[]; "only-when-bound"?: string }; const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { requires: Requirement[]; resources: Resource[]; + build: { artifacts: { name: string; kind: string; from?: string }[] }; }; +// A file's text is its template's, in the module's files/ (novox/hq ADR 0349 §2). +const text = (r: Resource): string => { + assert.ok(r.template, `${r.id} names no template`); + return readFileSync(new URL(`../${r.template}`, import.meta.url), "utf8"); +}; + +test("no file carries its text inline: each names its template, and the build packs files/ into one archive", () => { + for (const r of manifest.resources) { + assert.equal(r.content, undefined, `${r.id} keeps its text inline`); + if (r.type === "file") text(r); + } + const packs = manifest.build.artifacts.filter((a) => a.kind === "archive" && a.from === "files"); + assert.deepEqual(packs.map((a) => a.name), ["files"]); +}); const optional = (name: string) => manifest.requires.some((r) => typeof r !== "string" && r.name === name && r.optional === true); @@ -24,7 +39,7 @@ test("influxdb-api is optional, and route and oidc-client stay hard", () => { test("every file naming influxdb-api is delivered only while it is bound", () => { for (const r of manifest.resources) { - if (r.type === "file" && /\$\{(bound:influxdb-api:|secret:influxdb-api\})/.test(r.content ?? "")) { + if (r.type === "file" && /\$\{(bound:influxdb-api:|secret:influxdb-api\})/.test(text(r))) { assert.equal(r["only-when-bound"], "influxdb-api", `${r.id} names influxdb-api and is delivered while it is absent`); } } diff --git a/modules/hello-web/files/index.html b/modules/hello-web/files/index.html new file mode 100644 index 00000000..0d513fd5 --- /dev/null +++ b/modules/hello-web/files/index.html @@ -0,0 +1 @@ +hello from hello-web, routed by the mesh diff --git a/modules/hello-web/module.json b/modules/hello-web/module.json index 1811a1f1..44dfc471 100644 --- a/modules/hello-web/module.json +++ b/modules/hello-web/module.json @@ -38,7 +38,7 @@ "type": "file", "path": "${dir:state}/index.html", "mode": "0644", - "content": "hello from hello-web, routed by the mesh\n" + "template": "files/index.html" }, { "id": "net", @@ -70,6 +70,11 @@ "name": "server", "kind": "upstream", "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/home-assistant/files/config.json b/modules/home-assistant/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/home-assistant/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/home-assistant/files/provisions.env b/modules/home-assistant/files/provisions.env new file mode 100644 index 00000000..93aba0ca --- /dev/null +++ b/modules/home-assistant/files/provisions.env @@ -0,0 +1,4 @@ +MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123} +MESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index d35ae18e..8aff932f 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -45,6 +45,14 @@ } ] }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Home Assistant at, as a URL; unset, the code falls back to its environment, else this machine's Home Assistant on its port.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -86,15 +94,16 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"] }, { "id": "provisions-env", "type": "file", "path": "${dir:state}/provisions.env", "mode": "0600", - "content": "MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123}\nMESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n" + "template": "files/provisions.env" }, { "id": "provisions", @@ -185,6 +194,11 @@ "MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token", "MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/hostname/cmd/hostname-tools/hostsfile_test.go b/modules/hostname/cmd/hostname-tools/hostsfile_test.go index b8562b6f..f2811b0a 100644 --- a/modules/hostname/cmd/hostname-tools/hostsfile_test.go +++ b/modules/hostname/cmd/hostname-tools/hostsfile_test.go @@ -304,7 +304,7 @@ func TestTheMachinesNameIsTheOperatorsSetting(t *testing.T) { if r["path"] != "/etc/hostname" { continue } - if r["id"] != "name" || r["into"] != nil || r["content"] != "${setting:hostname}\n" { + if r["id"] != "name" || r["into"] != nil || templateText(t, r) != "${config:hostname}\n" { t.Errorf("/etc/hostname is not written whole from the hostname setting: %v", r) } return diff --git a/modules/hostname/cmd/hostname-tools/templates_test.go b/modules/hostname/cmd/hostname-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/hostname/cmd/hostname-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/hostname/files/hostname b/modules/hostname/files/hostname new file mode 100644 index 00000000..34c12a43 --- /dev/null +++ b/modules/hostname/files/hostname @@ -0,0 +1 @@ +${config:hostname} diff --git a/modules/hostname/files/hosts b/modules/hostname/files/hosts new file mode 100644 index 00000000..7c9c0735 --- /dev/null +++ b/modules/hostname/files/hosts @@ -0,0 +1,7 @@ +# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this +# block is the operator's: kept across every push, changed through the node-hostname verbs add and +# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver +# answers them. +127.0.0.1 localhost +::1 localhost +127.0.1.1 ${machine:name} diff --git a/modules/hostname/module.json b/modules/hostname/module.json index bd605df0..e84e7be2 100644 --- a/modules/hostname/module.json +++ b/modules/hostname/module.json @@ -12,6 +12,14 @@ ] } ], + "config": { + "hostname": { + "type": "string", + "description": "The machine's host name, written to /etc/hostname: the operator's name for this machine, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "own", @@ -20,14 +28,14 @@ "mode": "0644", "into": "block", "at": "start", - "content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n" + "template": "files/hosts" }, { "id": "name", "type": "file", "path": "/etc/hostname", "mode": "0644", - "content": "${setting:hostname}\n" + "template": "files/hostname" } ], "build": { @@ -42,6 +50,11 @@ "loads": [ "hostname-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/i3/README.md b/modules/i3/README.md index b1657c82..5903d847 100644 --- a/modules/i3/README.md +++ b/modules/i3/README.md @@ -18,7 +18,7 @@ The window manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase |---|---|---| | `~/.config/i3/` | owned directory | its contents other than `config` are found and kept | | `~/.config/i3/config.d/` | owned directory | other modules' drop-ins, and yours | -| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`config/config`](config/config) | +| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`files/config`](files/config) | | `/etc/lemurs/wms/i3` | owned | the login manager's session entry: `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`, the session's output in the journal (`journalctl -t x-session`) because the login manager's pipe has no reader | **Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/-.conf`. The diff --git a/modules/i3/cmd/i3-tools/manifest_test.go b/modules/i3/cmd/i3-tools/manifest_test.go index d0f8afc3..366a6723 100644 --- a/modules/i3/cmd/i3-tools/manifest_test.go +++ b/modules/i3/cmd/i3-tools/manifest_test.go @@ -28,6 +28,11 @@ type manifest struct { Environment struct { Variables map[string]string `json:"variables"` } `json:"environment"` + Items []struct { + Collection string `json:"collection"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` Shell []struct { For, Slot, Code string } `json:"shell"` @@ -46,6 +51,23 @@ func readManifest(t *testing.T) manifest { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + // The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807). + for _, it := range m.Items { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(it.Collection, ":") + switch { + case len(parts) == 3 && parts[0] == "shell": + m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)}) + case it.Collection == "environment" && it.Attributes["kind"] == "variable": + if m.Environment.Variables == nil { + m.Environment.Variables = map[string]string{} + } + m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + } return m } @@ -108,9 +130,10 @@ func TestItContributesTheSessionsExecLastAndTheDesktopsIdentity(t *testing.T) { func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testing.T) { m := readManifest(t) r := resource(t, m, "config") - raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) - if r["content"] != string(raw) || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil { - t.Fatal("the manifest carries config/config whole, as an owned file") + raw, _ := os.ReadFile(filepath.Join("..", "..", "files", "config")) + if _, inline := r["content"]; inline || r["template"] != "files/config" || len(raw) == 0 || + r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil { + t.Fatal("the manifest takes files/config whole as its template, as an owned file") } c := string(raw) lines := strings.Split(strings.TrimSpace(c), "\n") @@ -159,7 +182,11 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) { if r["path"] != "/etc/lemurs/wms/i3" || r["mode"] != "0755" { t.Fatalf("%v", r) } - if !strings.Contains(r["content"].(string), `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`) { + if r["template"] != "files/wms/i3" { + t.Fatalf("the entry's text is not its template files/wms/i3: %v", r["template"]) + } + entry, _ := os.ReadFile(filepath.Join("..", "..", "files", "wms", "i3")) + if !strings.Contains(string(entry), `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`) { t.Fatal("the entry runs the session's start, never i3 bare") } pkgs := map[string]bool{} @@ -219,7 +246,7 @@ func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) { if len(found) < 5 { t.Fatalf("expected the launcher, clipboard, wallpaper, bars and the laptop to contribute; found %v", found) } - main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) + main, _ := os.ReadFile(filepath.Join("..", "..", "files", "config")) text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1) // The operator's own files are not the catalogue's: the include line itself goes (a contribution may // name it in a comment, so only the line that is exactly it). @@ -230,3 +257,31 @@ func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) { t.Fatalf("with the contributions of %v: %v\n%s", found, err, out) } } + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + m := readManifest(t) + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if name, ok := r["template"].(string); ok { + if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil { + t.Errorf("resource %v names %s: %v", r["id"], name, err) + } + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/i3/config/config b/modules/i3/files/config similarity index 100% rename from modules/i3/config/config rename to modules/i3/files/config diff --git a/modules/i3/files/env-xdg-current-desktop b/modules/i3/files/env-xdg-current-desktop new file mode 100644 index 00000000..04ad29f0 --- /dev/null +++ b/modules/i3/files/env-xdg-current-desktop @@ -0,0 +1 @@ +i3 diff --git a/modules/i3/files/env-xdg-session-desktop b/modules/i3/files/env-xdg-session-desktop new file mode 100644 index 00000000..04ad29f0 --- /dev/null +++ b/modules/i3/files/env-xdg-session-desktop @@ -0,0 +1 @@ +i3 diff --git a/modules/i3/files/wms/i3 b/modules/i3/files/wms/i3 new file mode 100755 index 00000000..4e269ad2 --- /dev/null +++ b/modules/i3/files/wms/i3 @@ -0,0 +1,9 @@ +#!/bin/sh +# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's +# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3. +# Replaced at every push. +# +# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a +# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog). +# The session's output goes to the journal instead, under `journalctl -t x-session`. +exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc" diff --git a/modules/i3/files/xinitrc-last b/modules/i3/files/xinitrc-last new file mode 100644 index 00000000..e84ff3d7 --- /dev/null +++ b/modules/i3/files/xinitrc-last @@ -0,0 +1,2 @@ +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/modules/i3/module.json b/modules/i3/module.json index 5dee0726..29fc3e85 100644 --- a/modules/i3/module.json +++ b/modules/i3/module.json @@ -1,6 +1,7 @@ { "module": "i3", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -30,17 +31,29 @@ "i3_marks", "i3_scratchpad" ], - "environment": { - "variables": { - "XDG_CURRENT_DESKTOP": "i3", - "XDG_SESSION_DESKTOP": "i3" - } - }, - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "last", - "code": "# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log.\nexec i3 --shmlog-size=26214400\n" + "collection": "environment", + "name": "xdg-current-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_CURRENT_DESKTOP" + }, + "template": "files/env-xdg-current-desktop" + }, + { + "collection": "environment", + "name": "xdg-session-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_SESSION_DESKTOP" + }, + "template": "files/env-xdg-session-desktop" + }, + { + "collection": "shell:xinitrc:last", + "name": "xinitrc-last", + "template": "files/xinitrc-last" } ], "resources": [ @@ -74,14 +87,14 @@ "path": "${machine:account-home}/.config/i3/config", "owner": "${machine:account}", "mode": "0644", - "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR\n# 0212): the mesh places their lines near the end, each module's under a line naming it, where every\n# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read\n# after them, by the include at the very end, and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line to node-display-session, and the line goes from here in the same change.\n# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'\n# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,\n# polychromatic).\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' lines ####\n#########################################\n# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the\n# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's\n# under a line naming it.\n${contribution:node-display-session:config}\n#########################################\n###### Your own files ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n" + "template": "files/config" }, { "id": "session", "type": "file", "path": "/etc/lemurs/wms/i3", "mode": "0755", - "content": "#!/bin/sh\n# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's\n# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3.\n# Replaced at every push.\n#\n# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a\n# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog).\n# The session's output goes to the journal instead, under `journalctl -t x-session`.\nexec systemd-cat -t x-session /bin/sh \"$HOME/.xinitrc\"\n" + "template": "files/wms/i3" } ], "build": { @@ -96,6 +109,11 @@ "loads": [ "i3-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index f8e94ce0..8d3c9dda 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -3,7 +3,7 @@ The bars as a module (novox/hq ADR 0208, research 026/05). - Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition. - Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules + Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and places the blocks other modules contribute to it (ADR 0255, below). Requires `x11-display` on its own machine: its bars are i3bar's. - Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set @@ -29,18 +29,19 @@ The bars as a module (novox/hq ADR 0208, research 026/05). ## Blocks other modules contribute (novox/hq ADR 0255) A block that depends on the machine's hardware is not written here. The module that knows the hardware -contributes it to `node-bar` as data that says what it shows, and this module renders it: +contributes it as text, in this module's grammar, to the bar's collection of blocks: -- **What a contribution says:** which bar (`bottom`, `top`), which place (`resources`, beside the - processor, memory and disks; `status`, beside the sound), an order from 0 to 99, what it shows - (`battery`, `command`) and that kind's options. Nothing in it is i3status-rust's. -- **How it is rendered:** the claim's `renders.block` is a template (its source is `files/block.tmpl`) - that turns one piece into a `[[block]]`. The controller runs it and places the result where each bar - says `${contribution:node-bar:block:bar=…,place=…}`. Each bar places both of its places once; the - catalogue check refuses a holder that misses one. -- **Where it applies:** a contribution may name a capability the machine must report. The `power` - module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's - bar gets both and a desktop only the power draw. +- **What a contributor gives:** an item of the collection `contribution:node-bar:block`, the block text in + i3status-rust's grammar (a `[[block]]`), with the attributes `bar` (`bottom`, `top`), `place` (`resources`, + beside the processor, memory and disks; `status`, beside the sound) and `order` (0 to 99), and, where the + block depends on the hardware, the capability the machine must report. +- **How it is placed:** each bar says `${collected:contribution:node-bar:block:bar=…,place=…}`, where the node's + provisioner client fills the items that carry those attributes, in order, each under a line naming its + contributor. Each bar places both of its places once. Where the client does not fill the file, the controller + fills the same text the same way. +- **Where it applies:** an item may name a capability the machine must report. The `power` module's battery item + names `battery` and its power-draw item names `power-meter`, so the laptop's bar gets both and a desktop only + the power draw. - **A value from the bus:** a block that `shows: state` names another module's state, such as `power.draw`. The mesh grants this module the read, because a module on its machine offers it; the manifest names no other module. This module's bundle watches it and leaves the value's text in the diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go index 8314ce40..05c068e2 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -1,87 +1,27 @@ package main import ( - "bytes" - "encoding/json" - "fmt" "os" "path/filepath" "strings" "testing" - "text/template" ) -// i3status-rust renders the blocks other modules contribute (novox/hq ADR 0255): a bar-neutral piece -// — what it shows, on which bar, where — in its own grammar, through the template its claim gives. The -// controller runs the template; these tests run it the same way, with the one function it is given. - -func blockTemplate(t *testing.T) *template.Template { - t.Helper() - m := readManifest(t) - src, err := os.ReadFile("../../files/block.tmpl") - if err != nil { - t.Fatal(err) - } - if m.Claims[0].Renders["block"] != string(src) { - t.Fatal("the claim's template is not files/block.tmpl") - } - quote := func(v any) (string, error) { - b, err := json.Marshal(fmt.Sprint(v)) - return string(b), err - } - return template.Must(template.New("block").Funcs(template.FuncMap{"quote": quote}).Parse(string(src))) -} - -func render(t *testing.T, tmpl *template.Template, piece string) string { - t.Helper() - view := map[string]any{"module": "power", "machine": "laptop"} - if err := json.Unmarshal([]byte(piece), &view); err != nil { - t.Fatal(err) - } - var b bytes.Buffer - if err := tmpl.Execute(&b, view); err != nil { - t.Fatal(err) - } - if strings.Contains(b.String(), "") { - t.Fatalf("%s rendered a missing value: %q", piece, b.String()) - } - return b.String() -} - -func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) { - tmpl := blockTemplate(t) - got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`) - if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") || - !strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") { - t.Fatalf("%q", got) - } - got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`) - if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") { - t.Fatalf("%q", got) - } -} - -func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) { - got := render(t, blockTemplate(t), - `{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`) - want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n" - if got != want { - t.Fatalf("%q, want %q", got, want) - } -} +// The blocks other modules contribute are a collection of text (design 57 L3, novox/hq ADR 0349 clause 13): each +// contributor gives its block in i3status-rust's grammar, and the bars place the collection where they want it. func TestEachBarPlacesBothPlacesOnce(t *testing.T) { m := readManifest(t) for _, bar := range []string{"bottom", "top"} { - content := m.resource(t, bar+"-bar")["content"].(string) + content := m.text(t, bar+"-bar") for _, place := range []string{"resources", "status"} { - ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}" + ph := "${collected:contribution:node-bar:block:bar=" + bar + ",place=" + place + "}" if strings.Count(content, ph) != 1 { t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph)) } } } - bottom := m.resource(t, "bottom-bar")["content"].(string) + bottom := m.text(t, "bottom-bar") resources := strings.Index(bottom, "place=resources") if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) || !(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") && @@ -91,30 +31,33 @@ func TestEachBarPlacesBothPlacesOnce(t *testing.T) { } func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) { - bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string) + bottom := readManifest(t).text(t, "bottom-bar") if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) || strings.Contains(bottom, "mem_total_used") { t.Fatal("the memory block counts the cache as used") } } -func TestAContributedStateIsACustomBlockReadingThisMachinesValue(t *testing.T) { - tmpl := blockTemplate(t) - got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`) - want := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n" - if got != want { - t.Fatalf("%q, want %q", got, want) +func TestABarNamesNoTemplateOfItsOwnForTheBlocksItPlaces(t *testing.T) { + m := readManifest(t) + for _, id := range []string{"top-bar", "bottom-bar"} { + if strings.Contains(m.text(t, id), "${contribution:") { + t.Errorf("the %s still places a contribution the old way", id) + } } - got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw","key":"desk"}}`) - if !strings.Contains(got, "i3status-value power.draw desk") { - t.Fatalf("%q", got) + if _, err := os.Stat("../../files/block.tmpl"); err == nil { + t.Error("files/block.tmpl is still there, and no claim renders it") } - // And the bundle finds what it rendered. +} + +func TestTheBundleFindsTheValueARenderedBlockShows(t *testing.T) { + // What the power module's block renders on a machine called laptop. + got := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n" f := filepath.Join(t.TempDir(), "bottom-bar.toml") if err := os.WriteFile(f, []byte(got), 0o644); err != nil { t.Fatal(err) } - if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "desk"}) { + if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "laptop"}) { t.Fatalf("%v", w) } } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go index 5c76cde9..c33db7ec 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -14,6 +14,8 @@ import ( type manifest struct { Module string `json:"module"` Version string `json:"version"` + Moved bool `json:"moved"` + Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` @@ -23,7 +25,7 @@ type manifest struct { Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. - Contributions []contribution `json:"contributions"` + Contributions []any `json:"contributions"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` @@ -48,6 +50,53 @@ type shellCode struct { Code string `json:"code"` } +// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} + +// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of +// the collections shell::, in the order they are declared. +func (m manifest) shell(t *testing.T) []shellCode { + t.Helper() + var out []shellCode + for _, it := range m.Items { + parts := strings.Split(it.Collection, ":") + if len(parts) != 3 || parts[0] != "shell" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) + } + return out +} + +// environmentOf is the variables the module sets, read from the templates of its items of the environment. +func (m manifest) environmentOf(t *testing.T) *environment { + t.Helper() + var env *environment + for _, it := range m.Items { + if it.Collection != "environment" || it.Attributes["kind"] != "variable" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + if env == nil { + env = &environment{Variables: map[string]string{}} + } + env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + return env +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -60,6 +109,7 @@ func readManifest(t *testing.T) manifest { if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } + m.Environment = m.environmentOf(t) return m } @@ -87,26 +137,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { @@ -178,31 +268,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } -type contribution struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` -} +// displaySession is the collection the module's lines for the window manager are an item of. +const displaySession = "contribution:node-display-session:config" -// i3Lines is what the module contributes to the window manager. +// i3Lines is what the module contributes to the window manager: the text of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" && c.Kind == "config" { - out += c.Content + for _, it := range m.Items { + if it.Collection == displaySession { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + out += string(text) } } return out } -// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no +// old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) + if len(m.Contributions) != 0 { + t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } - if m.i3Lines() != string(want) { - t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + var templates []string + for _, it := range m.Items { + if it.Collection == displaySession { + templates = append(templates, it.Template) + } + } + if len(templates) != 1 || templates[0] != source { + t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) + } + if m.i3Lines() == "" { + t.Fatalf("the template %s holds no line", source) } } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index ec41d8db..e4811c48 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -13,11 +13,14 @@ import ( func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) { m := readManifest(t) + if !m.Moved { + t.Fatal("the module is not marked moved") + } if m.Module != "i3status-rust" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" || - len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" { + len(m.Claims[0].Renders) != 0 { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { @@ -35,13 +38,13 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "value": "files/bin/i3status-value", } { - m.sameAsSource(t, id, src) + m.fromTemplate(t, id, src) } } func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) { m := readManifest(t) - bottom := m.resource(t, "bottom-bar")["content"].(string) + bottom := m.text(t, "bottom-bar") for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} { if strings.Contains(bottom, never) { t.Errorf("the bottom bar has %s", never) @@ -57,9 +60,9 @@ func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSessi !strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") { t.Fatalf("%s", c) } - if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || - !strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) { - t.Fatalf("%+v", m.Shell) + if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "normal" || + !strings.Contains(m.shell(t)[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) { + t.Fatalf("%+v", m.shell(t)) } } @@ -68,3 +71,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl deleted file mode 100644 index 527a6426..00000000 --- a/modules/i3status-rust/files/block.tmpl +++ /dev/null @@ -1,26 +0,0 @@ -{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller -renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}} -{{- if eq .shows "battery" -}} -[[block]] -block = "battery" -{{with .options}}{{with .device}}device = {{quote .}} -{{end}}{{end -}} -format = "$icon $percentage{ $time|}" -charging_format = "$icon $percentage{ $time|}" -not_charging_format = "$icon $percentage" -full_format = "$icon $percentage" -empty_format = "$icon $percentage" -missing_format = "" -{{- else if eq .shows "command" -}} -[[block]] -block = "custom" -command = {{quote .options.command}} -interval = {{.options.interval}} -{{- else if eq .shows "state" -}} -[[block]] -block = "custom" -command = {{quote (printf "~/.local/bin/i3status-value %s %s" .options.state (or .options.key .machine))}} -interval = 5 -hide_when_empty = true -{{- end}} - diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml index 58ef2029..12e6805f 100644 --- a/modules/i3status-rust/files/bottom-bar.toml +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -74,7 +74,7 @@ format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" # Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from # what they show by this module's template, and only on a machine that has what they show. -${contribution:node-bar:block:bar=bottom,place=resources} +${collected:contribution:node-bar:block:bar=bottom,place=resources} [[block]] block = "docker" interval = 2 @@ -88,7 +88,7 @@ cmd = "pavucontrol" # Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and # power draw, where the machine has them. -${contribution:node-bar:block:bar=bottom,place=status} +${collected:contribution:node-bar:block:bar=bottom,place=status} # The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. [[block]] block = "weather" diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml index b767a765..be198e8a 100644 --- a/modules/i3status-rust/files/top-bar.toml +++ b/modules/i3status-rust/files/top-bar.toml @@ -34,4 +34,4 @@ interval = 60 # Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept # so that one that does is never dropped. -${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status} +${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status} diff --git a/modules/i3status-rust/files/xinitrc-normal b/modules/i3status-rust/files/xinitrc-normal new file mode 100644 index 00000000..888acdb8 --- /dev/null +++ b/modules/i3status-rust/files/xinitrc-normal @@ -0,0 +1,3 @@ +# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this +# brings it back. Once per session, ending with the session's own process ($$). +"$HOME/.local/bin/i3bar-watchdog" "$$" & diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index 373338f4..6704d0dc 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -1,6 +1,7 @@ { "module": "i3status-rust", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -10,10 +11,7 @@ "claims": [ { "name": "node-bar", - "scope": "node", - "renders": { - "block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- else if eq .shows \"state\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote (printf \"~/.local/bin/i3status-value %s %s\" .options.state (or .options.key .machine))}}\ninterval = 5\nhide_when_empty = true\n{{- end}}\n\n" - } + "scope": "node" } ], "tools": [ @@ -22,11 +20,16 @@ "i3status_rust_block_run", "i3status_rust_themes" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this\n# brings it back. Once per session, ending with the session's own process ($$).\n\"$HOME/.local/bin/i3bar-watchdog\" \"$$\" &\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/60-i3status-rust.conf" } ], "resources": [ @@ -55,7 +58,7 @@ "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n\n# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept\n# so that one that does is never dropped.\n${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}\n" + "template": "files/top-bar.toml" }, { "id": "bottom-bar", @@ -63,7 +66,7 @@ "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: a block that depends on the hardware is contributed by the module that\n# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written\n# here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\n# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure\n# module judges by. Counting the cache as used read near full on a machine with room to spare.\nformat = \"$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from\n# what they show by this module's template, and only on a machine that has what they show.\n${contribution:node-bar:block:bar=bottom,place=resources}\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and\n# power draw, where the machine has them.\n${contribution:node-bar:block:bar=bottom,place=status}\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + "template": "files/bottom-bar.toml" }, { "id": "icons", @@ -71,7 +74,7 @@ "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\n# Material from NerdFont\n# Codepoints from the Nerd Fonts cheat sheet\nbacklight = [\n \"\\ue38d\", # nf-weather-moon_new\n \"\\ue3d4\", # nf-weather-moon_alt_waxing_gibbous_6\n \"\\ue3d3\", # nf-weather-moon_alt_waxing_gibbous_5\n \"\\ue3d2\", # nf-weather-moon_alt_waxing_gibbous_4\n \"\\ue3d1\", # nf-weather-moon_alt_waxing_gibbous_3\n \"\\ue3d0\", # nf-weather-moon_alt_waxing_gibbous_2\n \"\\ue3cf\", # nf-weather-moon_alt_waxing_gibbous_1\n \"\\ue3ce\", # nf-weather-moon_alt_first_quarter\n \"\\ue3cd\", # nf-weather-moon_alt_waxing_crescent_6\n \"\\ue3cc\", # nf-weather-moon_alt_waxing_crescent_5\n \"\\ue3cb\", # nf-weather-moon_alt_waxing_crescent_4\n \"\\ue3ca\", # nf-weather-moon_alt_waxing_crescent_3\n \"\\ue3c9\", # nf-weather-moon_alt_waxing_crescent_2\n \"\\ue3c8\", # nf-weather-moon_alt_waxing_crescent_1\n \"\\ue39b\", # nf-weather-moon_full\n]\nbat_charging = \"\\U000f0084\" # nf-md-battery_charging\nbat_not_available = \"\\U000f0091\" # nf-md-battery_unknown\nbat = [\n \"\\U000f007a\", # nf-md-battery_10\n \"\\U000f007b\", # nf-md-battery_20\n \"\\U000f007c\", # nf-md-battery_30\n \"\\U000f007d\", # nf-md-battery_40\n \"\\U000f007e\", # nf-md-battery_50\n \"\\U000f007f\", # nf-md-battery_60\n \"\\U000f0080\", # nf-md-battery_70\n \"\\U000f0081\", # nf-md-battery_80\n \"\\U000f0082\", # nf-md-battery_90\n \"\\U000f0079\", # nf-md-battery\n]\nbell = \"\\U000f009c\" # nf-md-bell_outline\nbell-slash = \"\\U000f009b\" # nf-md-bell_off\nblackberry = \"\\uf307\" # nf-md-blackberry\nbluetooth = \"\\U000f00af\" # nf-md-bluetooth\ncalendar = \"\\U000f00ed\" # nf-md-calendar\ncogs = \"\\U000f0493\" # nf-md-cog\ncpu = [\n\t\"\\U000F0F86\", # nf-md-speedometer_slow\n\t\"\\U000F0F85\", # nf-md-speedometer_medium\n\t\"\\U000F04C5\", # nf-md-speedometer\n]\ncpu_boost_on = \"\\U000f0521\" # nf-md-toggle_switch\ncpu_boost_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ndisk_drive = \"\\U000f02ca\" # nf-md-harddisk\ndocker = \"\\uf308\" # nf-linux-docker\ngithub = \"\\U000f02a4\" # nf-md-github\ngpu = \"\\U000f0379\" # nf-md-monitor\nheadphones = \"\\U000f02cb\" # nf-md-headphones\njoystick = \"\\U000f0297\" # nf-md-gamepad_variant\nkeyboard = \"\\U000f030c\" # nf-md-keyboard\nmail = \"\\U000f01ee\" # nf-md-email\nmemory_mem = \"\\U000f035b\" # nf-md-memory\nmemory_swap = \"\\U000f02ca\" # nf-md-harddisk\nmouse = \"\\U000f037d\" # nf-md-mouse\nmusic = \"\\U000f075a\" # nf-md-music\nmusic_next = \"\\U000f04ad\" # nf-md-skip_next\nmusic_pause = \"\\U000f03e4\" # nf-md-pause\nmusic_play = \"\\U000f040a\" # nf-md-play\nmusic_prev = \"\\U000f04ae\" # nf-md-skip_previous\nnet_bridge = \"\\U000f04aa\" # nf-md-sitemap\nnet_down = \"\\U000f01da\" # nf-md-download\nnet_loopback = \"\\U000f006f\" # nf-md-backup_restore\nnet_modem = \"\\U000f03f2\" # nf-md-phone\nnet_cellular = [\n \"\\U000F08FD\", # nf-md-network_strength_off_outline\n \"\\U000F08FE\", # nf-md-network_strength_outline\n \"\\U000F08F4\", # nf-md-network_strength_1\n \"\\U000F08F6\", # nf-md-network_strength_2\n \"\\U000F08F8\", # nf-md-network_strength_3\n \"\\U000F08FA\", # nf-md-network_strength_4\n]\nnet_up = \"\\U000f0552\" # nf-md-upload\nnet_vpn = \"\\U000f0582\" # nf-md-vpn\nnet_wired = \"\\U000f0200\" # nf-md-ethernet\nnet_wireless = [\n\t\"\\U000F092F\", # nf-md-wifi_strength_outline\n\t\"\\U000F091F\", # nf-md-wifi_strength_1\n\t\"\\U000F0922\", # nf-md-wifi_strength_2\n\t\"\\U000F0925\", # nf-md-wifi_strength_3\n\t\"\\U000F0928\", # nf-md-wifi_strength_4\n]\nnotification = \"\\U000f009c\" # nf-md-bell_outline\nphone = \"\\U000f03f2\" # nf-md-phone\nphone_disconnected = \"\\U000f0658\" # nf-md-phone_minus\nping = \"\\U000f051f\" # nf-md-timer_sand\npomodoro = \"\\ue001\" # nf-pom-pomodoro_done\npomodoro_break = \"\\U000f0176\" # nf-md-coffee\npomodoro_paused = \"\\U000f03e4\" # nf-md-pause\npomodoro_started = \"\\U000f040a\" # nf-md-play\npomodoro_stopped = \"\\U000f04db\" # nf-md-stop\nresolution = \"\\U000f0293\" # nf-md-fullscreen\ntasks = \"\\U000f05c7\" # nf-md-playlist_check\ntea = \"\\U000f0d9e\" # nf-md-tea\nthermometer = [\n \"\\U000f10c3\", # nf-md-thermometer_low\n \"\\U000f050f\", # nf-md-thermometer\n \"\\U000f10c2\", # nf-md-thermometer_high\n]\ntime = \"\\U000f0150\" # nf-md-clock_outline\ntoggle_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ntoggle_on = \"\\U000f0521\" # nf-md-toggle_switch\nunknown = \"\\U000f0186\" # nf-md-comment_question_outline | TODO: Make default?\nupdate = \"\\U000f03d5\" # nf-md-package_up\nuptime = \"\\U000f0153\" # nf-md-clock_in\nvolume_muted = \"\\U000f075f\" # nf-md-volume_mute\nvolume = [\n \"\\U000f057f\", # nf-md-volume_low\n \"\\U000f0580\", # nf-md-volume_medium\n \"\\U000f057e\", # nf-md-volume_high\n]\nmicrophone_muted = \"\\U000f036d\" # nf-md-microphone_off\nmicrophone = [\n\t\"\\U000f036e\", # nf-md-microphone_outline\n \"\\U000f036c\", # nf-md-microphone\n \"\\U000f036c\", # nf-md-microphone\n]\nxrandr = \"\\U000f037a\" # nf-md-monitor_multiple\n\n# Weather icons (the names the weather block documents)\nweather_sun = \"\\ue30d\" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)\nweather_moon = \"\\ue32b\" # nf-weather-night_clear (when weather is reported as “Clear” at night)\nweather_clouds = \"\\ue312\" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)\nweather_clouds_night = \"\\ue37e\" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)\nweather_fog = \"\\ue313\" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)\nweather_fog_night = \"\\ue346\" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)\nweather_rain = \"\\ue318\" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)\nweather_rain_night = \"\\ue325\" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)\nweather_snow = \"\\ue31a\" # nf-weather-snow (when weather is reported as “Snow”)\nweather_thunder = \"\\ue31d\" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)\nweather_thunder_night = \"\\ue32a\" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)\n" + "template": "files/icons/custom-icons.toml" }, { "id": "updates", @@ -79,7 +82,7 @@ "path": "${machine:account-home}/.local/bin/i3status-updates", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for\n# the bottom bar's custom block (json = true). Emits one JSON line.\n#\n# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw\n# red error on every transient network failure, which on a roaming laptop is often. This keeps the\n# last good result through a failure and shows an error only after `threshold` failures in a row\n# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no\n# root); AUR packages through paru when it is installed, and none counted when it is not.\nset -uo pipefail\n\nthreshold=\"${1:-10}\"\nicon=\"update\"\nstate_dir=\"${XDG_RUNTIME_DIR:-/tmp}/i3status-updates\"\nmkdir -p \"$state_dir\"\nlast=\"$state_dir/last_good.json\"\nfailures=\"$state_dir/failures\"\n\njson() { printf '{\"icon\":\"%s\",\"state\":\"%s\",\"text\":\"%s\",\"short_text\":\"%s\"}' \"$icon\" \"$1\" \"$2\" \"$3\"; }\ncount() { [ -n \"$1\" ] && printf '%s\\n' \"$1\" | grep -c . || echo 0; }\n\nofficial=\"$(checkupdates 2>/dev/null)\"\nofficial_rc=$?\naur=\"\" aur_rc=0\nif command -v paru >/dev/null 2>&1; then\n\taur=\"$(paru -Qua 2>/dev/null)\"\n\taur_rc=$?\nfi\n\n# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.\nif { [ \"$official_rc\" -eq 0 ] || [ \"$official_rc\" -eq 2 ]; } && [ \"$aur_rc\" -eq 0 ]; then\n\to=\"$(count \"$official\")\"\n\ta=\"$(count \"$aur\")\"\n\ttotal=$((o + a))\n\tif [ \"$total\" -eq 0 ]; then\n\t\tpayload=\"$(json Good \"up to date\" \"\")\"\n\telse\n\t\t# A kernel update wants a reboot: the one worth not putting off.\n\t\tstate=Info\n\t\tprintf '%s\\n' \"$official\" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning\n\t\tlabel=\"$o + $a = $total updates\"\n\t\t[ \"$a\" -eq 0 ] && label=\"$total updates\"\n\t\t[ \"$total\" -eq 1 ] && label=\"1 update\"\n\t\tpayload=\"$(json \"$state\" \"$label\" \"$total\")\"\n\tfi\n\tprintf '%s' \"$payload\" >\"$last\"\n\tprintf '0' >\"$failures\"\n\tprintf '%s\\n' \"$payload\"\n\texit 0\nfi\n\nn=$(($(cat \"$failures\" 2>/dev/null || echo 0) + 1))\nprintf '%s' \"$n\" >\"$failures\"\nif [ \"$n\" -ge \"$threshold\" ]; then\n\tjson Critical \"update check failing (${n}x)\" \"!\"\n\techo\nelif [ -s \"$last\" ]; then\n\tcat \"$last\"\n\techo\nelse\n\tjson Idle \"\" \"\"\n\techo\nfi\n" + "template": "files/bin/i3status-updates" }, { "id": "watchdog", @@ -87,7 +90,7 @@ "path": "${machine:account-home}/.local/bin/i3bar-watchdog", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" + "template": "files/bin/i3bar-watchdog" }, { "id": "value", @@ -95,7 +98,7 @@ "path": "${machine:account-home}/.local/bin/i3status-value", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n" + "template": "files/bin/i3status-value" } ], "build": { @@ -110,14 +113,12 @@ "loads": [ "i3status-rust-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" - } - ] + } } diff --git a/modules/icecast/files/config.json b/modules/icecast/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/icecast/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/icecast/files/icecast.xml b/modules/icecast/files/icecast.xml new file mode 100644 index 00000000..de4dd792 --- /dev/null +++ b/modules/icecast/files/icecast.xml @@ -0,0 +1,56 @@ + + + Earth + icemaster@localhost + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + ${secret:source} + ${secret:relay} + admin + ${secret:admin} + + + localhost + + 8000 + + +
+ + 1 + + /usr/share/icecast + /var/log/icecast + /usr/share/icecast/web + /usr/share/icecast/admin + + + + access.log + error.log + 3 + 10000 + + + 0 + + + icecast + icecast + + + diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 4c241ebc..f8288be7 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -47,6 +47,20 @@ } ] }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Icecast at, as a URL; unset, the code falls back to its environment, else this machine's Icecast on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Icecast admin account the module's tools sign in as; unset, the code falls back to its environment, else admin.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -71,7 +85,7 @@ "type": "file", "path": "${dir:state}/icecast.xml", "mode": "0600", - "content": "\n \n Earth\n icemaster@localhost\n \n 100\n 2\n 524288\n 30\n 15\n 10\n 1\n 65535\n \n \n ${secret:source}\n ${secret:relay}\n admin\n ${secret:admin}\n \n \n localhost\n \n 8000\n \n \n
\n \n 1\n \n /usr/share/icecast\n /var/log/icecast\n /usr/share/icecast/web\n /usr/share/icecast/admin\n \n \n \n access.log\n error.log\n 3\n 10000\n \n \n 0\n \n \n icecast\n icecast\n \n \n\n" + "template": "files/icecast.xml" }, { "id": "net", @@ -100,8 +114,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["url", "user"] } ], "build": { @@ -123,6 +138,11 @@ "MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_ICECAST_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/influxdb/files/config.json b/modules/influxdb/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/influxdb/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/influxdb/files/server.env b/modules/influxdb/files/server.env new file mode 100644 index 00000000..d2552e00 --- /dev/null +++ b/modules/influxdb/files/server.env @@ -0,0 +1,6 @@ +DOCKER_INFLUXDB_INIT_MODE=setup +DOCKER_INFLUXDB_INIT_USERNAME=admin +DOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin +DOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token +DOCKER_INFLUXDB_INIT_ORG=mesh +DOCKER_INFLUXDB_INIT_BUCKET=default diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index 6bf2588f..2d73b8d7 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -26,24 +26,32 @@ "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant" } ], - "settings": { + "config": { "org": { - "kind": "preference", - "default": "mesh", - "why": "the organisation influxdb-api names to its consumers; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)" + "type": "string", + "description": "The organisation influxdb-api names to its consumers and the module's tools write to; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "mesh" }, "bucket": { - "kind": "preference", - "default": "default", - "why": "the bucket influxdb-api names to its consumers; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)" + "type": "string", + "description": "The bucket influxdb-api names to its consumers and the module's tools write to; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "default" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach influxdb at, as a URL; empty is this machine's influxdb on its port.", + "trusted": true, + "default": "" } }, "serves": { "influxdb-api": { "scheme": "http", "port": 8086, - "org": "${setting:org}", - "bucket": "${setting:bucket}" + "org": "${config:org}", + "bucket": "${config:bucket}" } }, "receives": { @@ -110,7 +118,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n" + "template": "files/server.env" }, { "id": "server", @@ -135,9 +143,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", "merge": "json", - "takes-settings": [ + "takes-config": [ "bucket", "org", "url" @@ -174,6 +182,11 @@ "MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/invoicing/files/api.env b/modules/invoicing/files/api.env new file mode 100644 index 00000000..8eaee6f0 --- /dev/null +++ b/modules/invoicing/files/api.env @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=9000 +MONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as} +MONGO_DB=${bound:mongodb-database:as} +MINIO_BUCKET=${bound:s3-bucket:bucket} +MINIO_ENDPOINT=${bound:s3-bucket:at} +MINIO_PORT=${bound:s3-bucket:port} +MINIO_ACCESSKEY=${bound:s3-bucket:as} +MINIO_SECRET=${secret:s3-bucket} diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json index 9f8dfcf5..5b846d1c 100644 --- a/modules/invoicing/module.json +++ b/modules/invoicing/module.json @@ -68,7 +68,7 @@ "type": "file", "path": "${dir:state}/api.env", "mode": "0600", - "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" + "template": "files/api.env" }, { "id": "net", @@ -113,5 +113,14 @@ "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" } } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/jira/files/config.json b/modules/jira/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/modules/jira/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/jira/module.json b/modules/jira/module.json index 84a00079..eceb279d 100644 --- a/modules/jira/module.json +++ b/modules/jira/module.json @@ -4,6 +4,20 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the jira tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the jira tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", @@ -16,8 +30,18 @@ "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] } ], "capabilities": [ @@ -39,6 +63,11 @@ "MESH_JIRA_TOKEN_FILE": "${dir:state}/token", "MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/kernel/README.md b/modules/kernel/README.md new file mode 100644 index 00000000..09405354 --- /dev/null +++ b/modules/kernel/README.md @@ -0,0 +1,52 @@ +# kernel + +The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one: +**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot +entries) is not built yet. + +## Why (novox/hq issue 746) + +With transparent huge pages `always`, the page cache of a file mapped by a program is collapsed into +2 MiB folios (khugepaged), and a small write into such a folio dirties all of it: writeback then writes +2 MiB. journald writes its journal files through mmap, and every journal reader (fail2ban's journal +jails among them) maps them too. Measured on the control node on 2026-10-14: journald's cgroup had +written 2.5 TB in 12 days while the journal grew by about 25-30 KB/s, fail2ban's cgroup 63 GB in 4 h +while its own files did not grow, both holding hundreds of MiB of file pages in huge folios; the bursts +saturated the mirrored spinning root disk that holds the bus store and the store, and the controller +lost its lease four times in 30 minutes. With `madvise`, only memory a program asks to have in huge pages +(`madvise(MADV_HUGEPAGE)`) gets them; a database or a JVM that wants them still asks. + +## What it owns + +| | what | notes | +|---|---|---| +| setting | `transparent-huge-pages` | `always`, `madvise` or `never`. Default `madvise`, the value the mesh chose for every node (ADR 0381), not what the distribution's kernel boots with (`always`); a machine that needs another value is given it as its own setting, with its reason | +| file | `${dir:state}/huge-pages` | the script, written from the setting: `apply` writes the mode to `/sys/kernel/mm/transparent_hugepage/enabled` and reads it back (a value that does not take fails the unit); `restore` gives back the mode found before the module first set it, kept in `${dir:state}/found` | +| file | `/etc/systemd/system/mesh-kernel-huge-pages.service` | oneshot, early in boot (`sysinit.target`, before the services that fill the page cache); reloading it runs `apply`, stopping it runs `restore`, which also removes `found`. Sandboxed: the file system read-only but for the module's state (`/sys` stays writable), no home, no network, no new privileges; the journal's rotation goes over its local socket, which a private network leaves reachable | +| service | `mesh-kernel-huge-pages.service` | running and enabled; **reloaded** when the script (so the setting) changes, so a new value is set directly and never passes back through the found one; restarted only when the unit changes; its health is the unit's | + +The per-size controls (`hugepages-kB/enabled`) are left alone: on the machines read, 2048 kB says +`inherit` (it follows the mode) and every other size `never`. `kernel_huge_pages` shows each one. + +**A folio already in the page cache keeps its size.** A new mode applies to pages faulted or collapsed +from then on. The active journal files would keep their 2 MiB folios until archived, so `apply` rotates +the journal (`journalctl --rotate`) whenever it moves the mode away from `always`: new files are mapped +under the new mode. A failed rotation is said in the unit's journal and does not fail the setting. No +rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing` +or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit. + +## Tools + +| tool | | answers | +|---|---|---| +| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) | + +## Tests + +`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the +manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh +writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once, +rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a +reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel +does not take before writing anything, fails when the write does not take, and says when the kernel has +no transparent huge pages. diff --git a/modules/kernel/cmd/kernel-tools/hugepages.go b/modules/kernel/cmd/kernel-tools/hugepages.go new file mode 100644 index 00000000..697f4fd8 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/hugepages.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "os" + "path" + "sort" + "strconv" + "strings" +) + +// THPDir is where the kernel says and takes its transparent huge pages. +const THPDir = "sys/kernel/mm/transparent_hugepage" + +// CgroupRoot is the unified cgroup hierarchy. +const CgroupRoot = "sys/fs/cgroup" + +// HugePagesAnswer is what kernel_huge_pages answers. +type HugePagesAnswer struct { + // Mode is the word the kernel brackets in enabled: always, madvise or never. + Mode string `json:"mode"` + Defrag string `json:"defrag,omitempty"` + Shmem string `json:"shmem_enabled,omitempty"` + PerSize []Size `json:"per_size,omitempty"` + // Meminfo is the machine's huge pages by kind, in MiB. + Meminfo map[string]float64 `json:"meminfo_mib,omitempty"` + Units []Unit `json:"units"` + Said []string `json:"said"` +} + +// Size is one per-size control (hugepages-kB/enabled). +type Size struct { + Size string `json:"size"` + Enabled string `json:"enabled"` + // Means is what inherit resolves to: the top-level mode. + Means string `json:"means"` +} + +// Unit is one cgroup holding file pages in huge folios. +type Unit struct { + Cgroup string `json:"cgroup"` + FileTHP int64 `json:"file_thp_bytes"` + FileDirty int64 `json:"file_dirty_bytes"` + FileWriteback int64 `json:"file_writeback_bytes"` + FileMapped int64 `json:"file_mapped_bytes"` + CollapsedByKhug int64 `json:"thp_collapse_alloc"` + Written int64 `json:"written_bytes"` +} + +// bracketed is the word a sysfs choice file brackets: "always [madvise] never" is madvise. +func bracketed(text string) string { + open := strings.IndexByte(text, '[') + end := strings.IndexByte(text, ']') + if open < 0 || end < open { + return "" + } + return text[open+1 : end] +} + +// HugePages reads the mode, the per-size controls, meminfo and the top units by file_thp. +func (m *Machine) HugePages(units int) (*HugePagesAnswer, error) { + enabled, err := m.read(THPDir + "/enabled") + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("this kernel has no /%s/enabled: it was built without transparent huge pages", THPDir) + } + return nil, fmt.Errorf("reading /%s/enabled: %w", THPDir, err) + } + a := &HugePagesAnswer{Mode: bracketed(enabled), Units: []Unit{}} + if s, err := m.read(THPDir + "/defrag"); err == nil { + a.Defrag = bracketed(s) + } + if s, err := m.read(THPDir + "/shmem_enabled"); err == nil { + a.Shmem = bracketed(s) + } + a.Said = append(a.Said, fmt.Sprintf("transparent huge pages: %s, defrag %s", a.Mode, orUnknown(a.Defrag))) + + sizes, _ := m.dirs(THPDir) + for _, d := range sizes { + if !strings.HasPrefix(d, "hugepages-") { + continue + } + s, err := m.read(THPDir + "/" + d + "/enabled") + if err != nil { + continue + } + sz := Size{Size: strings.TrimPrefix(d, "hugepages-"), Enabled: bracketed(s)} + sz.Means = sz.Enabled + if sz.Enabled == "inherit" { + sz.Means = a.Mode + } + a.PerSize = append(a.PerSize, sz) + } + sort.Slice(a.PerSize, func(i, j int) bool { return kib(a.PerSize[i].Size) < kib(a.PerSize[j].Size) }) + var on []string + for _, s := range a.PerSize { + if s.Means != "never" { + on = append(on, s.Size+" "+s.Means) + } + } + if len(a.PerSize) > 0 { + a.Said = append(a.Said, "sizes not never: "+orNone(on)) + } + + if mi, err := m.read("proc/meminfo"); err == nil { + a.Meminfo = meminfo(mi) + a.Said = append(a.Said, fmt.Sprintf("huge pages held: anonymous %.0f MiB, shared memory %.0f MiB, file %.0f MiB (mapped at 2 MiB %.0f MiB)", + a.Meminfo["AnonHugePages"], a.Meminfo["ShmemHugePages"], a.Meminfo["FileHugePages"], a.Meminfo["FilePmdMapped"])) + } + + if units > 0 { + all := m.cgroups() + sort.Slice(all, func(i, j int) bool { + if all[i].FileTHP != all[j].FileTHP { + return all[i].FileTHP > all[j].FileTHP + } + return all[i].Cgroup < all[j].Cgroup + }) + if len(all) > units { + all = all[:units] + } + a.Units = all + for _, u := range all { + a.Said = append(a.Said, fmt.Sprintf("%s: file in huge folios %s, dirty %s, writeback %s, written %s since it started", + u.Cgroup, mib(u.FileTHP), mib(u.FileDirty), mib(u.FileWriteback), mib(u.Written))) + } + if len(all) == 0 { + a.Said = append(a.Said, "no unit holds file pages in huge folios") + } + } + return a, nil +} + +// cgroups walks the leaf services and scopes (to three levels below the root) that hold file_thp. +func (m *Machine) cgroups() []Unit { + var out []Unit + var walk func(rel string, depth int) + walk = func(rel string, depth int) { + children, _ := m.dirs(path.Join(CgroupRoot, rel)) + for _, c := range children { + p := path.Join(rel, c) + if strings.HasSuffix(c, ".slice") { + if depth < 3 { + walk(p, depth+1) + } + continue + } + if !strings.HasSuffix(c, ".service") && !strings.HasSuffix(c, ".scope") { + continue + } + stat, err := m.read(path.Join(CgroupRoot, p, "memory.stat")) + if err != nil { + continue + } + kv := memoryStat(stat) + if kv["file_thp"] == 0 { + continue + } + u := Unit{Cgroup: p, FileTHP: kv["file_thp"], FileDirty: kv["file_dirty"], FileWriteback: kv["file_writeback"], + FileMapped: kv["file_mapped"], CollapsedByKhug: kv["thp_collapse_alloc"]} + if io, err := m.read(path.Join(CgroupRoot, p, "io.stat")); err == nil { + u.Written = mostWritten(io) + } + out = append(out, u) + } + } + walk("", 0) + return out +} + +// memoryStat reads memory.stat's "key value" lines. +func memoryStat(text string) map[string]int64 { + kv := map[string]int64{} + for _, l := range strings.Split(text, "\n") { + f := strings.Fields(l) + if len(f) != 2 { + continue + } + if n, err := strconv.ParseInt(f[1], 10, 64); err == nil { + kv[f[0]] = n + } + } + return kv +} + +// mostWritten is the most bytes any one device of io.stat saw written: a mirror's member disks and the +// mirror itself each count the same write, so the lines are never summed. +func mostWritten(text string) int64 { + var most int64 + for _, l := range strings.Split(text, "\n") { + for _, f := range strings.Fields(l) { + if v, ok := strings.CutPrefix(f, "wbytes="); ok { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > most { + most = n + } + } + } + } + return most +} + +// meminfo reads the huge page lines of /proc/meminfo, in MiB. +func meminfo(text string) map[string]float64 { + want := map[string]bool{"AnonHugePages": true, "ShmemHugePages": true, "ShmemPmdMapped": true, "FileHugePages": true, "FilePmdMapped": true} + out := map[string]float64{} + for _, l := range strings.Split(text, "\n") { + k, v, ok := strings.Cut(l, ":") + if !ok || !want[k] { + continue + } + f := strings.Fields(v) + if len(f) == 0 { + continue + } + if n, err := strconv.ParseFloat(f[0], 64); err == nil { + out[k] = n / 1024 + } + } + return out +} + +func kib(size string) int { + n, _ := strconv.Atoi(strings.TrimSuffix(size, "kB")) + return n +} + +func mib(b int64) string { return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) } + +func orUnknown(s string) string { + if s == "" { + return "unknown" + } + return s +} + +func orNone(list []string) string { + if len(list) == 0 { + return "none" + } + return strings.Join(list, ", ") +} diff --git a/modules/kernel/cmd/kernel-tools/hugepages_test.go b/modules/kernel/cmd/kernel-tools/hugepages_test.go new file mode 100644 index 00000000..2d6359c7 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/hugepages_test.go @@ -0,0 +1,113 @@ +package main + +// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for +// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and +// journald's memory.stat and io.stat, cut to the lines the tool reads. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func tree(t *testing.T, files map[string]string) *Machine { + t.Helper() + root := t.TempDir() + for p, content := range files { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return &Machine{Root: root} +} + +func novox(t *testing.T) *Machine { + thp := "sys/kernel/mm/transparent_hugepage/" + cg := "sys/fs/cgroup/system.slice/" + files := map[string]string{ + thp + "enabled": "[always] madvise never\n", + thp + "defrag": "always defer defer+madvise [madvise] never\n", + thp + "shmem_enabled": "always within_size [advise] never deny force\n", + thp + "hugepages-2048kB/enabled": "always [inherit] madvise never\n", + thp + "hugepages-64kB/enabled": "always inherit madvise [never]\n", + thp + "hugepages-1024kB/enabled": "always inherit madvise [never]\n", + "proc/meminfo": "MemTotal: 65748808 kB\nAnonHugePages: 3579904 kB\nShmemHugePages: 4096 kB\n" + + "ShmemPmdMapped: 4096 kB\nFileHugePages: 1693696 kB\nFilePmdMapped: 882688 kB\nHugepagesize: 2048 kB\n", + cg + "fail2ban.service/memory.stat": "anon 21815296\nfile 204623872\nfile_mapped 130736128\nfile_dirty 27262976\n" + + "file_writeback 4096\nanon_thp 0\nfile_thp 197132288\nthp_fault_alloc 0\nthp_collapse_alloc 550\n", + cg + "fail2ban.service/io.stat": "9:0 \n8:16 rbytes=3223552 wbytes=67723415552 rios=240 wios=281159 dbytes=0 dios=0\n" + + "8:0 rbytes=6152192 wbytes=67723415552 rios=832 wios=281159 dbytes=0 dios=0\n" + + "9:2 rbytes=9375744 wbytes=67723415552 rios=1072 wios=281159 dbytes=0 dios=0\n", + cg + "systemd-journald.service/memory.stat": "anon 3227648\nfile 1032015872\nfile_mapped 131928064\nfile_dirty 622592\n" + + "file_writeback 159744\nanon_thp 2097152\nfile_thp 379584512\nthp_fault_alloc 0\nthp_collapse_alloc 11741\n", + cg + "systemd-journald.service/io.stat": "9:0 \n8:16 rbytes=55209984 wbytes=2498768035840 rios=1317 wios=24719004 dbytes=0 dios=0\n" + + "8:0 rbytes=72953856 wbytes=2498768039936 rios=2194 wios=24719005 dbytes=0 dios=0\n" + + "9:2 rbytes=128163840 wbytes=2498724843520 rios=3511 wios=24707873 dbytes=0 dios=0\n", + cg + "sshd.service/memory.stat": "anon 1000\nfile_thp 0\n", + } + return tree(t, files) +} + +func TestNovoxBeforeTheChange(t *testing.T) { + a, err := novox(t).HugePages(10) + if err != nil { + t.Fatal(err) + } + if a.Mode != "always" || a.Defrag != "madvise" || a.Shmem != "advise" { + t.Fatalf("modes: %+v", a) + } + if len(a.PerSize) != 3 || a.PerSize[0].Size != "64kB" || a.PerSize[2].Size != "2048kB" || + a.PerSize[2].Enabled != "inherit" || a.PerSize[2].Means != "always" || a.PerSize[0].Means != "never" { + t.Fatalf("per size: %+v", a.PerSize) + } + if a.Meminfo["FileHugePages"] != 1654 || a.Meminfo["FilePmdMapped"] != 862 { + t.Fatalf("meminfo: %v", a.Meminfo) + } + if len(a.Units) != 2 || a.Units[0].Cgroup != "system.slice/systemd-journald.service" || + a.Units[1].Cgroup != "system.slice/fail2ban.service" { + t.Fatalf("units: %+v", a.Units) + } + // The mirror and its two members each count the write: the most any one saw, never their sum. + if a.Units[1].Written != 67723415552 || a.Units[1].FileTHP != 197132288 || a.Units[1].CollapsedByKhug != 550 { + t.Fatalf("fail2ban: %+v", a.Units[1]) + } + said := strings.Join(a.Said, "\n") + for _, want := range []string{"transparent huge pages: always, defrag madvise", "sizes not never: 2048kB always", + "file 1654 MiB (mapped at 2 MiB 862 MiB)", "system.slice/fail2ban.service: file in huge folios 188.0 MiB"} { + if !strings.Contains(said, want) { + t.Errorf("said lacks %q:\n%s", want, said) + } + } +} + +func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) { + a, err := novox(t).HugePages(1) + if err != nil || len(a.Units) != 1 { + t.Fatalf("%v %+v", err, a) + } + b, err := tree(t, map[string]string{"sys/kernel/mm/transparent_hugepage/enabled": "always [madvise] never\n"}).HugePages(10) + if err != nil || b.Mode != "madvise" || len(b.Units) != 0 || !strings.Contains(strings.Join(b.Said, "\n"), "no unit holds") { + t.Fatalf("%v %+v", err, b) + } +} + +func TestAKernelWithoutHugePagesIsAnError(t *testing.T) { + if _, err := tree(t, map[string]string{"proc/meminfo": ""}).HugePages(10); err == nil || + !strings.Contains(err.Error(), "built without transparent huge pages") { + t.Fatalf("%v", err) + } +} + +func TestTheArgumentIsBounded(t *testing.T) { + if _, err := whole(map[string]any{"units": -1.0}, "units", 10, 0, 100); err == nil { + t.Error("a negative count was accepted") + } + if n, _ := whole(map[string]any{"units": 1000.0}, "units", 10, 0, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } +} diff --git a/modules/kernel/cmd/kernel-tools/machine.go b/modules/kernel/cmd/kernel-tools/machine.go new file mode 100644 index 00000000..b5f6c730 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/machine.go @@ -0,0 +1,65 @@ +package main + +// The machine as the tools see it: the files they read, under a root that tests replace with a stand-in +// tree. Everything read here is readable by the runtime's account; nothing is escalated. + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// Machine reads this machine's kernel files under Root ("/" on the machine). +type Machine struct { + Root string +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { return &Machine{Root: "/"} } + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +func (m *Machine) read(p string) (string, error) { + b, err := os.ReadFile(m.path(p)) + return string(b), err +} + +func (m *Machine) dirs(p string) ([]string, error) { + entries, err := os.ReadDir(m.path(p)) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if e.IsDir() || e.Type()&fs.ModeSymlink != 0 { + out = append(out, e.Name()) + } + } + return out, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// schema is a tool's input: its properties. +func schema(properties map[string]any) map[string]any { + return map[string]any{"type": "object", "properties": properties} +} diff --git a/modules/kernel/cmd/kernel-tools/main.go b/modules/kernel/cmd/kernel-tools/main.go new file mode 100644 index 00000000..ec15c916 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/main.go @@ -0,0 +1,50 @@ +// The kernel module's tools bundle (novox/hq to-be 42 Phase 1 row 11, issue 746). +// +// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193). It only reads: +// the transparent huge pages the kernel runs with, and which units hold file pages in huge folios, which +// is what a small write to a mapped file costs a whole huge page of writeback for (issue 746). +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "kernel-tools" + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): kernel. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "kernel_huge_pages", + Description: "Transparent huge pages on this machine: the mode in force (always, madvise or never) and defrag, " + + "each per-size control with what inherit resolves to, the machine's huge pages by kind (anonymous, " + + "shared memory, file, file mapped at 2 MiB) from /proc/meminfo, and the units holding the most file " + + "pages in huge folios (memory.stat file_thp) with their dirty and writeback bytes and the bytes their " + + "cgroup has written (io.stat, the most any one device saw). A unit with file_thp that writes far more " + + "than its files grow is paying a whole huge page of writeback per small write (novox/hq issue 746). " + + "Replaces cat /sys/kernel/mm/transparent_hugepage/enabled, grep Huge /proc/meminfo and reading " + + "memory.stat by hand. (r)", + Input: schema(map[string]any{ + "units": map[string]any{"type": "integer", "description": "how many units to name, the most file_thp first (default 10, 0 for none, at most 100)"}, + }), + Run: func(args map[string]any) (any, error) { + n, err := whole(args, "units", 10, 0, 100) + if err != nil { + return nil, err + } + return m.HugePages(n) + }, + }, + } +} diff --git a/modules/kernel/cmd/kernel-tools/manifest_test.go b/modules/kernel/cmd/kernel-tools/manifest_test.go new file mode 100644 index 00000000..b68dacf5 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/manifest_test.go @@ -0,0 +1,239 @@ +package main + +// The module's shape (novox/hq issue 746): the setting's default is madvise, the value the mesh chose +// (ADR 0381), not what the kernel boots with; the script is written into the +// module's own state and run by a sandboxed oneshot unit that is reloaded (applied again) when the script, +// and so the setting, changes and restarted only when the unit does; and the script itself sets, verifies, +// gives back and refuses as it says. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) { + m := manifest(t) + s := m.Config["transparent-huge-pages"] + if s["type"] != "string" || s["default"] != "madvise" || s["description"] == "" || s["trusted"] != true { + t.Fatalf("the setting: %v", s) + } + var ids []string + for _, r := range m.Resources { + ids = append(ids, r["id"].(string)) + } + if strings.Join(ids, " ") != "state huge-pages unit apply" { + t.Fatalf("order: %v", ids) + } + script := m.resource(t, "huge-pages") + // The script runs as root, so the setting it is written from is set at the terminal alone (issue 339). + if script["path"] != "${dir:state}/huge-pages" || script["mode"] != "0755" || script["trusted"] != true || + !strings.Contains(script.text(t), "want='${config:transparent-huge-pages}'") { + t.Fatalf("the script: %v", script["path"]) + } + unit := m.resource(t, "unit").text(t) + for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/huge-pages apply", + "ExecReload=${dir:state}/huge-pages apply", "ExecStop=${dir:state}/huge-pages restore", "WantedBy=sysinit.target", + "DefaultDependencies=no", "ProtectSystem=strict", "ReadWritePaths=${dir:state}", "ProtectHome=yes", "PrivateNetwork=yes", + "NoNewPrivileges=yes"} { + if !strings.Contains(unit, line+"\n") { + t.Errorf("the unit lacks %q", line) + } + } + apply := m.resource(t, "apply") + if apply["unit"] != "mesh-kernel-huge-pages.service" || apply["state"] != "running" || apply["boot"] != "enabled" { + t.Fatalf("apply: %v", apply) + } + // A new value reloads (apply again); only a new unit restarts, whose stop would pass back through the + // mode the machine was found with. + if on := apply["restart-on"].([]any); len(on) != 1 || on[0] != "unit" { + t.Fatalf("restart-on: %v", on) + } + if on := apply["reload-on"].([]any); len(on) != 1 || on[0] != "huge-pages" { + t.Fatalf("reload-on: %v", on) + } + if h := apply["health"].(map[string]any); h["kind"] != "unit" { + t.Fatalf("health: %v", h) + } +} + +// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage +// directory, the module's state, and a PATH whose journalctl records that it was asked to rotate. +type bed struct { + script, sys, state, rotated, path, bin string +} + +func newBed(t *testing.T, want, found string) bed { + return newBedIn(t, t.TempDir(), want, found) +} + +// newBedIn writes the script for want into dir; a second bed in the same dir shares the machine and the +// state, as a reload with a new value does. +func newBedIn(t *testing.T, dir, want, found string) bed { + t.Helper() + b := bed{script: filepath.Join(dir, "huge-pages"), sys: filepath.Join(dir, "thp"), state: filepath.Join(dir, "state"), + rotated: filepath.Join(dir, "rotated")} + content := manifest(t).resource(t, "huge-pages").text(t) + content = strings.ReplaceAll(content, "${config:transparent-huge-pages}", want) + content = strings.ReplaceAll(content, "${dir:state}", b.state) + if strings.Contains(content, "${") { + t.Fatalf("a substitution the test does not make is left in the script") + } + bin := filepath.Join(dir, "bin") + for _, d := range []string{b.sys, b.state, bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + write(t, b.script, content, 0o755) + if found != "" { + write(t, filepath.Join(b.sys, "enabled"), found, 0o644) + } + write(t, filepath.Join(bin, "journalctl"), "#!/bin/sh\necho \"$@\" >>"+b.rotated+"\n", 0o755) + b.bin = bin + b.booted(t, "running") + b.path = bin + string(os.PathListSeparator) + os.Getenv("PATH") + return b +} + +// booted makes the service manager say state for is-system-running. +func (b bed) booted(t *testing.T, state string) { + t.Helper() + write(t, filepath.Join(b.bin, "systemctl"), "#!/bin/sh\n[ \"$1\" = is-system-running ] && echo "+state+"\n", 0o755) +} + +func write(t *testing.T, p, content string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func (b bed) run(t *testing.T, verb string) (string, error) { + t.Helper() + cmd := exec.Command("sh", b.script, verb, b.sys, b.state) + cmd.Env = append(os.Environ(), "PATH="+b.path) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (b bed) file(t *testing.T, p string) string { + t.Helper() + raw, err := os.ReadFile(p) + if err != nil { + return "" + } + return string(raw) +} + +func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) { + // novox's file on 2026-10-14, read through node-reads. + b := newBed(t, "madvise", "[always] madvise never\n") + out, err := b.run(t, "apply") + if err != nil || !strings.Contains(out, "always -> madvise") { + t.Fatalf("apply: %v %s", err, out) + } + if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" { + t.Fatalf("written: %q", got) + } + if got := b.file(t, filepath.Join(b.state, "found")); got != "always\n" { + t.Fatalf("found: %q", got) + } + if got := b.file(t, b.rotated); got != "--rotate\n" { + t.Fatalf("rotation: %q", got) + } + // Applied again (a restart with the same value): nothing written, nothing rotated, found kept. + if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise, as set") { + t.Fatalf("again: %v %s", err, out) + } + if got := b.file(t, b.rotated); got != "--rotate\n" { + t.Fatalf("rotated again: %q", got) + } + if out, err := b.run(t, "restore"); err != nil || !strings.Contains(out, "back to always") { + t.Fatalf("restore: %v %s", err, out) + } + if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "always\n" { + t.Fatalf("restored: %q", got) + } + // Nothing of the module is kept once it is stopped. + if _, err := os.Stat(filepath.Join(b.state, "found")); !os.IsNotExist(err) { + t.Fatalf("found kept after restore: %v", err) + } +} + +func TestAReloadWithANewValueGoesStraightThereAndNeverRewritesFound(t *testing.T) { + dir := t.TempDir() + b := newBedIn(t, dir, "madvise", "[always] madvise never\n") + if out, err := b.run(t, "apply"); err != nil { + t.Fatalf("apply: %v %s", err, out) + } + // The setting changes to never: the mesh rewrites the script and reloads the unit, which applies again. + n := newBedIn(t, dir, "never", "") + out, err := n.run(t, "apply") + if err != nil || !strings.Contains(out, "madvise -> never") { + t.Fatalf("reload: %v %s", err, out) + } + if got := n.file(t, filepath.Join(n.state, "found")); got != "always\n" { + t.Fatalf("found rewritten by a reload: %q", got) + } +} + +func TestNoRotationWhileTheMachineIsStarting(t *testing.T) { + for _, state := range []string{"initializing", "starting"} { + b := newBed(t, "madvise", "[always] madvise never\n") + b.booted(t, state) + out, err := b.run(t, "apply") + if err != nil || !strings.Contains(out, "still starting") { + t.Fatalf("%s: %v %s", state, err, out) + } + if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" { + t.Fatalf("%s: not set: %q", state, got) + } + if got := b.file(t, b.rotated); got != "" { + t.Fatalf("%s: rotated at boot: %q", state, got) + } + } +} + +func TestAlwaysIsSetWithoutRotating(t *testing.T) { + b := newBed(t, "always", "always [madvise] never\n") + if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise -> always") { + t.Fatalf("apply: %v %s", err, out) + } + if got := b.file(t, b.rotated); got != "" { + t.Fatalf("rotated for always: %q", got) + } +} + +func TestAValueTheKernelDoesNotTakeIsRefusedBeforeAnythingIsWritten(t *testing.T) { + b := newBed(t, "sometimes", "[always] madvise never\n") + out, err := b.run(t, "apply") + if err == nil || !strings.Contains(out, "takes always, madvise or never") { + t.Fatalf("accepted: %v %s", err, out) + } + if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "[always] madvise never\n" { + t.Fatalf("written anyway: %q", got) + } +} + +func TestAWriteThatDoesNotTakeFailsTheUnit(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root writes a read-only file") + } + b := newBed(t, "madvise", "[always] madvise never\n") + if err := os.Chmod(filepath.Join(b.sys, "enabled"), 0o444); err != nil { + t.Fatal(err) + } + if out, err := b.run(t, "apply"); err == nil { + t.Fatalf("a refused write passed: %s", out) + } +} + +func TestAKernelWithoutHugePagesIsSaid(t *testing.T) { + b := newBed(t, "madvise", "") + if out, err := b.run(t, "apply"); err == nil || !strings.Contains(out, "built without transparent huge pages") { + t.Fatalf("%v %s", err, out) + } +} diff --git a/modules/kernel/cmd/kernel-tools/shape_test.go b/modules/kernel/cmd/kernel-tools/shape_test.go new file mode 100644 index 00000000..2350b801 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/shape_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Config map[string]map[string]any `json:"config"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(&Machine{Root: t.TempDir()}) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} + +// text is the file text a resource keeps in its template under files/ (novox/hq ADR 0349 §2). +func (r resource) text(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("../..", r["template"].(string))) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/modules/kernel/files/huge-pages.sh b/modules/kernel/files/huge-pages.sh new file mode 100644 index 00000000..e0a98155 --- /dev/null +++ b/modules/kernel/files/huge-pages.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746). +# Written by the mesh from this machine's setting transparent-huge-pages and run by +# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the +# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the +# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode +# moves straight to the new value and never passes back through the one the machine was found with. +# +# huge-pages apply|restore [ []] +# +# The two directories are arguments only so the module's tests can run this against a stand-in tree. +set -eu + +want='${config:transparent-huge-pages}' +sys=/sys/kernel/mm/transparent_hugepage +state='${dir:state}' +[ $# -ge 2 ] && sys=$2 +[ $# -ge 3 ] && state=$3 + +case "$want" in +always | madvise | never) ;; +*) + echo "the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never" >&2 + exit 2 + ;; +esac +[ -f "$sys/enabled" ] || { + echo "this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set" >&2 + exit 1 +} + +# The mode in force is the word the kernel brackets: "always [madvise] never". A file holding one bare +# word (the stand-in tree of the tests) is read as that word. +mode() { sed -n -e 's/.*\[\([a-z]*\)\].*/\1/p' -e t -e 's/^ *\([a-z]*\) *$/\1/p' "$sys/enabled"; } + +[ $# -ge 1 ] || set -- usage +case "$1" in +apply) + now=$(mode) + # What the machine had before the mesh first set it, kept once, so `restore` gives it back. + [ -s "$state/found" ] || printf '%s\n' "$now" >"$state/found" + if [ "$now" = "$want" ]; then + echo "transparent huge pages: $want, as set" + exit 0 + fi + printf '%s\n' "$want" >"$sys/enabled" + got=$(mode) + [ "$got" = "$want" ] || { + echo "wrote $want to $sys/enabled and the kernel says $got" >&2 + exit 1 + } + echo "transparent huge pages: $now -> $want" + # A file folio already in the page cache keeps its size: the active journal files, mapped by journald + # and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new + # files that are mapped under the new mode. A failed rotation is said and does not fail the setting. + # At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation + # while the service manager is still bringing the machine up. + booting=$(systemctl is-system-running 2>/dev/null || true) + case "$booting" in + initializing | starting) echo "the machine is still starting: nothing to rotate yet" ;; + *) + if [ "$want" != always ] && command -v journalctl >/dev/null 2>&1; then + journalctl --rotate || echo "the journal could not be rotated: its active files keep their huge pages until they are archived" >&2 + fi + ;; + esac + ;; +restore) + found=$(cat "$state/found" 2>/dev/null || true) + case "$found" in + always | madvise | never) + printf '%s\n' "$found" >"$sys/enabled" + echo "transparent huge pages: back to $found, as the machine had them" + ;; + *) echo "transparent huge pages: nothing recorded as found, left at $(mode)" ;; + esac + # Unassigned or shut down: nothing of the module is kept; the next start records what it finds again. + rm -f "$state/found" + ;; +*) + echo "usage: huge-pages apply|restore [ []]" >&2 + exit 2 + ;; +esac diff --git a/modules/kernel/files/mesh-kernel-huge-pages.service b/modules/kernel/files/mesh-kernel-huge-pages.service new file mode 100644 index 00000000..77a90595 --- /dev/null +++ b/modules/kernel/files/mesh-kernel-huge-pages.service @@ -0,0 +1,25 @@ +[Unit] +Description=Transparent huge pages as the mesh sets them on this machine +# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting +# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the +# mode the machine had before. It runs early in boot, before the services that fill the page cache. +DefaultDependencies=no +After=local-fs.target +Before=sysinit.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/huge-pages apply +ExecReload=${dir:state}/huge-pages apply +ExecStop=${dir:state}/huge-pages restore +# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket. +ProtectSystem=strict +ReadWritePaths=${dir:state} +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=sysinit.target diff --git a/modules/kernel/go.mod b/modules/kernel/go.mod new file mode 100644 index 00000000..748c098c --- /dev/null +++ b/modules/kernel/go.mod @@ -0,0 +1,5 @@ +module kernel + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/kernel/go.sum b/modules/kernel/go.sum new file mode 100644 index 00000000..b474419e --- /dev/null +++ b/modules/kernel/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/kernel/module.json b/modules/kernel/module.json new file mode 100644 index 00000000..7305c945 --- /dev/null +++ b/modules/kernel/module.json @@ -0,0 +1,77 @@ +{ + "module": "kernel", + "version": "1", + "capabilities": [ + "service-manager" + ], + "config": { + "transparent-huge-pages": { + "type": "string", + "description": "madvise is the value the mesh chose for every node (novox/hq ADR 0381), not the always the distribution's kernel boots with; a machine that needs another value is given it as its own setting; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)", + "trusted": true, + "default": "madvise" + } + }, + "tools": [ + "kernel_huge_pages" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "huge-pages", + "type": "file", + "path": "${dir:state}/huge-pages", + "mode": "0755", + "trusted": true, + "template": "files/huge-pages.sh" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-huge-pages.service", + "mode": "0644", + "template": "files/mesh-kernel-huge-pages.service" + }, + { + "id": "apply", + "type": "service", + "unit": "mesh-kernel-huge-pages.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit" + ], + "reload-on": [ + "huge-pages" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/kernel-tools", + "binary": "kernel-tools", + "loads": [ + "kernel-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/keycloak/cmd/keycloak-provider/main.go b/modules/keycloak/cmd/keycloak-provider/main.go index ee6e9d0e..d8f9334f 100644 --- a/modules/keycloak/cmd/keycloak-provider/main.go +++ b/modules/keycloak/cmd/keycloak-provider/main.go @@ -9,6 +9,7 @@ package main import ( "context" "os" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -43,12 +44,33 @@ func main() { go guard.Run(context.Background()) var h *Harness - if receives := os.Getenv("MESH_RECEIVES"); receives == "" { - say("MESH_RECEIVES is not set — the provisioner cannot run without it") - } else if issuer, err := Issuer(os.Getenv); err != nil { + if issuer, err := Issuer(os.Getenv); err != nil { say("%v — the provisioner cannot run without it", err) } else if realm, err := RealmOf(issuer); err != nil { say("%v — the provisioner cannot run without it", err) + } else if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" { + // Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its + // machine's client fills the file with; the grant directory is not read. Retirement is the + // harness's, never run, so its guards and its tools stand (issue 805). + h = &Harness{ + Resource: ProvisionName, + Adapter: provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr}, + Log: logStderr, + Announce: announce, + Node: os.Getenv("MESH_NODE"), + } + run, p, bus, err := moved(consumers, h, issuer) + if err != nil { + say("%v", err) + } else { + go func() { + if err := run.Run(context.Background(), p, bus); err != nil { + say("the provider helper stopped: %v", err) + } + }() + } + } else if receives := os.Getenv("MESH_RECEIVES"); receives == "" { + say("MESH_RECEIVES is not set — the provisioner cannot run without it") } else { h = &Harness{ Resource: "oidc-client", @@ -62,9 +84,43 @@ func main() { } go h.Run(context.Background()) } + // Started on the grant path while the module is to be handed, it restarts itself when its client first fills + // the consumers' file, so the first fill moves it onto the helper without a hand restart (issue 805): `restart-on` + // names a service or a container, and the only one here is Keycloak's own. + if at := os.Getenv("MESH_CONSUMERS_FILE"); at != "" && handedConsumersFile(os.Getenv, os.Stat) == "" { + go exitWhenFilled(at, os.Stat, 5*time.Second, func() { + say("%s is filled: restarting to answer from it", at) + os.Exit(0) + }) + } // The retirement tools beside keycloak's own (novox/hq ADR 0230). if err := stdio.Serve("", append(Tools(kc, guard), RetirementTools(h)...)); err != nil { say("%v", err) os.Exit(1) } } + +// handedConsumersFile is the consumers' file when keycloak is handed to the provisioner: the file MESH_CONSUMERS_FILE +// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not +// there, and keycloak then reads the grant directory exactly as it always did (issue 805). +func handedConsumersFile(getenv func(string) string, stat func(string) (os.FileInfo, error)) string { + at := getenv("MESH_CONSUMERS_FILE") + if at == "" { + return "" + } + if _, err := stat(at); err != nil { + return "" + } + return at +} + +// exitWhenFilled calls filled once the file is there, asking every so often. +func exitWhenFilled(at string, stat func(string) (os.FileInfo, error), every time.Duration, filled func()) { + for { + if _, err := stat(at); err == nil { + filled() + return + } + time.Sleep(every) + } +} diff --git a/modules/keycloak/cmd/keycloak-provider/moved.go b/modules/keycloak/cmd/keycloak-provider/moved.go new file mode 100644 index 00000000..34ce390f --- /dev/null +++ b/modules/keycloak/cmd/keycloak-provider/moved.go @@ -0,0 +1,274 @@ +package main + +// keycloak handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issue 805): where its consumers file +// is set, keycloak runs on the SDK's provider helper instead of the harness reading the controller's grant files. +// +// The helper learns who binds keycloak from the plans, composes each consumer's entry and reads its credential +// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks +// here: Make brings the consumer's OIDC client into being exactly as the harness's adapter does, and Gone leaves +// retirement to the harness's guards (retirement.go): the client is disabled and marked to delete, never removed. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// ProvisionName is what keycloak provides, as its manifest says. +const ProvisionName = "oidc-client" + +// servedValues is what its manifest serves (`serves.oidc-client`): the paths as they are, and the issuer as its own +// config says, which is the one the code reads (Issuer), not a value the helper reads from the manifest. +func servedValues(issuer string) map[string]any { + return map[string]any{ + "issuer": issuer, + "authorization-path": "/protocol/openid-connect/auth", + "token-path": "/protocol/openid-connect/token", + "userinfo-path": "/protocol/openid-connect/userinfo", + } +} + +// valuesOf is what the consumer's grant file gave the harness as its values: what it contributes (label, endpoint, +// callback), with the names the controller composed for it (name, internal-name) over them (issue 792). +func valuesOf(e provider.Entry) map[string]any { + values := map[string]any{} + for k, v := range e.Contributes { + values[k] = v + } + for k, name := range e.Names { + values[k] = name + } + return values +} + +// movedRun is keycloak on the provider helper, with the harness's own memory and retirement behind it: what is held, +// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's +// Make and Gone and the loop below drive the same state, so the guards are the harness's, not a copy. +type movedRun struct { + h *Harness + + mu sync.Mutex // guards assignments and loaded + assignments map[string][]byte + loaded bool + verified map[string]time.Time +} + +func newMovedRun(h *Harness) *movedRun { + h.mu.Lock() + h.init() + h.mu.Unlock() + return &movedRun{h: h, assignments: map[string][]byte{}, verified: map[string]time.Time{}} +} + +// Make brings one consumer's client into being under its client id (its plan's `as`) and the vault's credential as +// its secret, as the harness does: at once when it is new or changed, and otherwise only when Keycloak no longer +// holds it, which is asked every VerifyEvery. +func (m *movedRun) Make(ctx context.Context, c provider.Consumer) error { + h, e := m.h, c.Entry + if e.As == "" { + return fmt.Errorf("%s has no client id to make", e.Key()) + } + if len(c.Credential) == 0 { + return fmt.Errorf("%s has no credential to make its client with", e.Key()) + } + h.mu.Lock() + defer h.mu.Unlock() + as, password := e.As, string(c.Credential) + p := Provision{As: as, Password: password, Values: valuesOf(e), Consumer: e.Consumer} + hash := hashOf(as, password, p.Values, nil) + if was, ok := h.applied[as]; ok && was.hash == hash { + if h.Now().Sub(m.verified[as]) < h.VerifyEvery { + return nil + } + m.verified[as] = h.Now() + hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) + held, err := h.Adapter.Holds(hctx, p) + cancel() + if err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + if held { + h.succeeded(as) + return nil + } + h.say("%s: Keycloak no longer holds it; applying again", as) + } + if err := h.Adapter.Create(ctx, p); err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + h.succeeded(as) + h.applied[as] = appliedEntry{hash: hash, node: e.Consumer} + m.verified[as] = h.Now() + h.reenabled(as, e.Consumer) + return nil +} + +// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is +// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time +// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing: +// the helper is told no, and asks again at its next pass, until the client is retired, or asked for again. +func (m *movedRun) Gone(_ context.Context, e provider.Entry) error { + h := m.h + as := e.As + if as == "" { + return nil // refused: nothing was made for it + } + // The plans as they are now, not as the last retirement pass read them: with none read, nothing is let go. + want, read := m.asked(h.Node) + asked := want[as] + h.mu.Lock() + defer h.mu.Unlock() + _, held := h.applied[as] + _, retired := h.r.retired[as] + switch { + case !read: + case asked: + return nil // another holder of the same client id still binds: nothing to retire + case retired, !held && h.r.seeded: + return nil + } + return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+ + "person approves it", as, h.StablePasses, h.StableFor) +} + +// plan is as much of an assignment as is read to learn who binds keycloak. +type plan struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Provision string `json:"provision"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + // As is who the consumer is at its provider's end, its slug first (issue 788). + As string `json:"as"` + } `json:"provisions"` + } `json:"provisioning"` +} + +// asked is the client ids the plans bind keycloak for, and whether they could all be read: a plan that is not the +// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241). +func (m *movedRun) asked(node string) (map[string]bool, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.loaded || len(m.assignments) == 0 { + return nil, false + } + want := map[string]bool{} + for key, raw := range m.assignments { + var a plan + if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key { + return nil, false + } + if a.Provisioning == nil { + continue + } + for _, b := range a.Provisioning.Provisions { + if b.Provider == node && b.ProviderModule == "keycloak" && b.Provision == ProvisionName { + as := b.As + if as == "" { + as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none + } + want[as] = true + } + } + } + return want, true +} + +// retire is one pass of the harness's retirement over what the plans bind now. +func (m *movedRun) retire(ctx context.Context) { + h := m.h + want, ok := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + if !ok { + // Not a result: the passes that must agree before anything is retired start again. + h.r.key, h.r.count = "", 0 + return + } + h.seed(ctx, want) + h.retireUnasked(ctx, want) + h.r.lastWant = want + for as := range h.failing { + if !want[as] { + delete(h.failing, as) + } + } + for as := range h.trouble { + if _, held := h.applied[as]; !want[as] && !held { + h.recovered(as, "no longer asked for") + } + } +} + +// watch keeps the plans as the bus holds them, from the first read on. +func (m *movedRun) watch(ctx context.Context, bus provider.Bus) error { + err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) { + m.mu.Lock() + defer m.mu.Unlock() + if value == nil { + delete(m.assignments, key) + } else { + m.assignments[key] = append([]byte(nil), value...) + } + }) + if err == nil { + m.mu.Lock() + m.loaded = true + m.mu.Unlock() + } + return err +} + +// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends. +func (m *movedRun) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error { + if err := m.watch(ctx, bus); err != nil { + return err + } + go func() { + for { + m.retire(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(m.h.Every): + } + } + }() + return p.Run(ctx) +} + +// moved is keycloak handed to the provisioner: the helper answering its consumers, and the harness (never run) whose +// retirement and tools it shares. consumersFile is where its machine's client fills its consumers, issuer the one +// keycloak's own config gives (Issuer), served to every consumer. +func moved(consumersFile string, h *Harness, issuer string) (*movedRun, *provider.Provider, provider.Bus, error) { + if consumersFile == "" { + return nil, nil, nil, errors.New("no consumers' file") + } + run := newMovedRun(h) + bus := provider.StateBus("keycloak") + return run, provider.New(provider.Options{ + Node: h.Node, + Module: "keycloak", + Offers: map[string]provider.Offer{ProvisionName: { + Credential: provider.PerConsumer, + Serves: servedValues(issuer), + }}, + Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, + Make: run.Make, + Gone: run.Gone, + Every: h.Every, + Logf: func(f string, a ...any) { h.say(f, a...) }, + }), bus, nil +} diff --git a/modules/keycloak/cmd/keycloak-provider/moved_test.go b/modules/keycloak/cmd/keycloak-provider/moved_test.go new file mode 100644 index 00000000..cfd33f39 --- /dev/null +++ b/modules/keycloak/cmd/keycloak-provider/moved_test.go @@ -0,0 +1,394 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +const testIssuer = "https://id.example.org/realms/Novox" + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// bind is one consumer's assignment, whose plan names this machine's keycloak as the provider and carries what the +// consumer contributes (issue 776). +func (b *bucketBus) bind(node, module string, contributes map[string]any) { + b.bindNamed(node, module, contributes, nil) +} + +// bindNamed is bind with the names the controller composed for the consumer (issue 792). +func (b *bucketBus) bindNamed(node, module string, contributes map[string]any, names map[string]string) { + plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", + "contributes": contributes, "names": names}}}}) + if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil { + panic(err) + } +} + +// consumersFile writes the file the machine's client fills: one credential per consumer. +func consumersFile(t *testing.T, bound map[[2]string]string) string { + var list []provider.ConsumerCredential + for k, password := range bound { + list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1], + Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password}) + } + raw, _ := json.Marshal(list) + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, raw, 0o600); err != nil { + t.Fatal(err) + } + return at +} + +// movedWorld is keycloak on the helper over an in-memory bus, with a recorder for a server and a clock to move. +type movedWorld struct { + t *testing.T + a *recorder + bus *bucketBus + h *Harness + run *movedRun + p *provider.Provider + now time.Time + file string +} + +func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld { + w := &movedWorld{t: t, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}}, + now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + w.file = consumersFile(t, bound) + w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now }, + Log: func(string, ...any) {}} + var bus provider.Bus + w.run, w.p, bus, _ = moved(w.file, w.h, testIssuer) + _ = bus + if err := w.run.watch(ctx, w.bus); err != nil { + t.Fatal(err) + } + return w +} + +// pass is one helper pass and one retirement pass, five seconds after the last. +func (w *movedWorld) pass() []provider.Outcome { + w.now = w.now.Add(5 * time.Second) + // the plans as the bus holds them now, as the watch would hand them + w.run.assignments = map[string][]byte{} + for k, v := range w.bus.buckets[provider.AssignmentsBucket] { + w.run.assignments[k] = v + } + // the helper reads its buckets from the in-memory bus + w.p = provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, + Serves: servedValues(testIssuer)}}, + Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}}) + out, err := w.p.Pass(ctx) + if err != nil { + w.t.Fatal(err) + } + w.run.retire(ctx) + return out +} + +func bindings(n int) (map[[2]string]string, []string) { + bound := map[[2]string]string{} + var logins []string + for i := 1; i <= n; i++ { + module := fmt.Sprintf("app%d", i) + bound[[2]string{"ace", module}] = "pw-" + module + logins = append(logins, provider.ConsumerIdentity("ace", module)) + } + return bound, logins +} + +// grafanaContributes and grafanaNames are what grafana's plan entry carries for keycloak: its contribution, and the +// names the controller composed for it (issues 776, 792). +var grafanaContributes = map[string]any{"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth"} +var grafanaNames = map[string]string{"name": "grafana.example.org", "internal-name": "grafana.home.internal"} + +// The consumers' file makes the same call on the adapter as the equivalent grant file does (the harness): the same +// client id, secret, and the values the redirect URIs are made from. +func TestTheConsumersFileMakesWhatTheGrantFileMakes(t *testing.T) { + grafana := provider.ConsumerIdentity("home", "grafana") + values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} + for k, v := range grafanaContributes { + values[k] = v + } + + // Today: the grant file, through the harness. + g := newWorld(t) + g.give(map[string]any{"as": grafana, "node": "home", "values": values}) + g.h.Reconcile(ctx) + + // The consumers' file, through the provider helper. + w := newMovedWorld(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana}) + w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || len(g.a.created) != 1 { + t.Fatalf("created %d through the helper, %d through the harness, want 1 each", len(w.a.created), len(g.a.created)) + } + c, want := w.a.created[0], g.a.created[0] + if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || + !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { + t.Fatalf("made %+v, the grant file makes %+v", c, want) + } + if _, redirects, err := RedirectsOf(c.Values); err != nil || + !reflect.DeepEqual(redirects, []string{"https://grafana.example.org/login/generic_oauth", "https://grafana.home.internal/login/generic_oauth"}) { + t.Fatalf("redirects %v, %v", redirects, err) + } + // The entry tells grafana what keycloak serves, the issuer from its own config. + var entry provider.Entry + if err := json.Unmarshal(w.bus.buckets[provider.BucketOf("keycloak")]["home.grafana.oidc-client"], &entry); err != nil { + t.Fatal(err) + } + if entry.Values["issuer"] != testIssuer || entry.Values["token-path"] != "/protocol/openid-connect/token" || + entry.Values["authorization-path"] != "/protocol/openid-connect/auth" || entry.Values["userinfo-path"] != "/protocol/openid-connect/userinfo" { + t.Fatalf("served %v", entry.Values) + } + + // Unchanged, and held: nothing is made again, within a minute or after it. No longer held: made again. + w.a.held = true + w.pass() + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 1 { + t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) + } + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 2 { + t.Fatalf("not made again: %d", len(w.a.created)) + } +} + +// Against a Keycloak, both paths leave the same client. +func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) { + grafana := provider.ConsumerIdentity("home", "grafana") + values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} + for k, v := range grafanaContributes { + values[k] = v + } + made := func(through func(p provisioner)) Rep { + f, o := oidcWorld(t) + through(provisioner{clients: o, announce: func(string, map[string]any) {}, log: func(string, ...any) {}}) + return only(t, f, grafana) + } + viaGrant := made(func(p provisioner) { + g := newWorld(t) + g.h.Adapter = p + g.give(map[string]any{"as": grafana, "node": "home", "values": values}) + g.h.Reconcile(ctx) + }) + viaFile := made(func(p provisioner) { + w := newMovedWorld(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana}) + w.h.Adapter = p + w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames) + w.pass() + }) + // Keycloak's own ids differ between two runs; nothing the mesh sets does. + for _, c := range []Rep{viaGrant, viaFile} { + delete(c, "id") + raw, _ := json.Marshal(c) + var again Rep + _ = json.Unmarshal(raw, &again) + for k := range c { + delete(c, k) + } + for k, v := range again { + c[k] = v + } + if mappers, ok := c["protocolMappers"].([]any); ok { + for _, m := range mappers { + delete(m.(map[string]any), "id") + } + } + } + if !reflect.DeepEqual(viaGrant, viaFile) { + t.Fatalf("through the file %v, through the grant %v", viaFile, viaGrant) + } +} + +// A consumer that is no longer bound is kept until the harness's retirement is stable, then retired, never deleted. +func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { + bound, logins := bindings(2) + w := newMovedWorld(t, bound) + w.bus.bindNamed("ace", "app1", grafanaContributes, grafanaNames) + w.bus.bindNamed("ace", "app2", grafanaContributes, grafanaNames) + w.pass() + + saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"] + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v on one read", w.a.removed) + } + w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved) + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v", w.a.removed) + } + + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + for i := 0; i < 6; i++ { + w.pass() + } + if len(w.a.removed) != 0 { + t.Fatalf("retired %v before the time was out", w.a.removed) + } + w.now = w.now.Add(11 * time.Minute) + w.pass() + w.pass() + if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 { + t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted) + } + if _, kept := w.bus.buckets[provider.BucketOf("keycloak")]["ace.app2.oidc-client"]; kept { + t.Fatal("the entry stayed after the client was retired") + } +} + +// A plan list that was never read is not nobody asking. +func TestNoPlansReadRetiresNothing(t *testing.T) { + bound, _ := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bindNamed("ace", "app1", grafanaContributes, grafanaNames) + w.pass() + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1") + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(20 * time.Minute) + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v with no plans read", w.a.removed) + } +} + +// A consumer whose credential is not in the file yet is waited for: nothing is made for it. +func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames) + outcomes := w.pass() + if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 { + t.Fatalf("%+v, created %v", outcomes, w.a.created) + } +} + +// A consumer whose plan names its client id (a slugged module) keeps it, and the retirement counts it as asked for. +func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { + agent := provider.ConsumerIdentity("home", "dashboard") + w := newMovedWorld(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}) + plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana-2", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", + "as": agent, "contributes": grafanaContributes, "names": grafanaNames}}}}) + if err := w.bus.Put(ctx, provider.AssignmentsBucket, "home.grafana-2", plan); err != nil { + t.Fatal(err) + } + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || w.a.created[0].As != agent { + t.Fatalf("made %+v, want one under %s", w.a.created, agent) + } + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v: the plan's client id was counted unasked", w.a.removed) + } +} + +// Where the file is not there, keycloak takes the grant directory, as before; where it is, the helper. +func TestTheGrantPathIsTakenWhereTheConsumersFileIsAbsent(t *testing.T) { + dir := t.TempDir() + at := filepath.Join(dir, "consumers.json") + env := func(k string) string { + if k == "MESH_CONSUMERS_FILE" { + return at + } + return "" + } + if got := handedConsumersFile(env, os.Stat); got != "" { + t.Fatalf("took %q for a file that is not there", got) + } + if got := handedConsumersFile(func(string) string { return "" }, os.Stat); got != "" { + t.Fatalf("took %q with no environment", got) + } + if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + if got := handedConsumersFile(env, os.Stat); got != at { + t.Fatalf("took %q, want %q", got, at) + } +} + +// The grant path restarts itself when the consumers file is first filled. +func TestTheGrantPathLeavesWhenTheConsumersFileIsFilled(t *testing.T) { + at := filepath.Join(t.TempDir(), "consumers.json") + done := make(chan struct{}) + go exitWhenFilled(at, os.Stat, time.Millisecond, func() { close(done) }) + select { + case <-done: + t.Fatal("left before the file was there") + case <-time.After(20 * time.Millisecond): + } + if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("did not leave when the file was filled") + } +} diff --git a/modules/keycloak/cmd/keycloak-provider/templates_test.go b/modules/keycloak/cmd/keycloak-provider/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/keycloak/cmd/keycloak-provider/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/keycloak/files/admin.env b/modules/keycloak/files/admin.env new file mode 100644 index 00000000..3e52ae5f --- /dev/null +++ b/modules/keycloak/files/admin.env @@ -0,0 +1,2 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${secret:admin} diff --git a/modules/keycloak/files/config.json b/modules/keycloak/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/keycloak/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/keycloak/files/consumers.json b/modules/keycloak/files/consumers.json new file mode 100644 index 00000000..b0af957c --- /dev/null +++ b/modules/keycloak/files/consumers.json @@ -0,0 +1 @@ +${consumers:oidc-client} diff --git a/modules/keycloak/files/database.env b/modules/keycloak/files/database.env new file mode 100644 index 00000000..454ee994 --- /dev/null +++ b/modules/keycloak/files/database.env @@ -0,0 +1,3 @@ +KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +KC_DB_USERNAME=${bound:postgres-database:as} +KC_DB_PASSWORD=${secret:postgres-database} diff --git a/modules/keycloak/files/hostname.env b/modules/keycloak/files/hostname.env new file mode 100644 index 00000000..92f061a1 --- /dev/null +++ b/modules/keycloak/files/hostname.env @@ -0,0 +1 @@ +KC_HOSTNAME=https://${bound:route:name} diff --git a/modules/keycloak/go.mod b/modules/keycloak/go.mod index a7950e5e..f8dcc045 100644 --- a/modules/keycloak/go.mod +++ b/modules/keycloak/go.mod @@ -2,4 +2,4 @@ module keycloak go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.7 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 diff --git a/modules/keycloak/go.sum b/modules/keycloak/go.sum index b474419e..9a531b0e 100644 --- a/modules/keycloak/go.sum +++ b/modules/keycloak/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 8bbf9d82..03749aa6 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -1,6 +1,7 @@ { "module": "keycloak", "version": "1", + "moved": true, "upgrade": { "policy": "record", "why": "every person's sign-in to every site goes through it, and its new version migrates its database on start: a person takes each build, after a backup (hq ADR 0236)" @@ -63,7 +64,7 @@ "authorization-path": "/protocol/openid-connect/auth", "token-path": "/protocol/openid-connect/token", "userinfo-path": "/protocol/openid-connect/userinfo", - "issuer": "${setting:issuer}" + "issuer": "${config:issuer}" } }, "receives": { @@ -84,6 +85,32 @@ } } }, + "config": { + "url": { + "type": "string", + "description": "The address the module's provider reaches Keycloak's admin API at, as a URL; empty is this machine's Keycloak on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Keycloak administrator the module's provider signs in as; empty is admin.", + "trusted": true, + "default": "" + }, + "realm": { + "type": "string", + "description": "The realm the module's provider makes its consumers' clients in; empty is master.", + "trusted": true, + "default": "" + }, + "issuer": { + "type": "string", + "description": "The issuer Keycloak's tokens name and oidc consumers are told, as the realm's URL, such as https://keycloak.example.org/realms/Example; the operator's, with no default (novox/hq ADR 0112, ADR 0155).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "mesh-state", @@ -102,19 +129,26 @@ "type": "directory", "mode": "0700" }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, { "id": "admin-env", "type": "file", "path": "${dir:state}/admin.env", "mode": "0600", - "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n" + "template": "files/admin.env" }, { "id": "database-env", "type": "file", "path": "${dir:state}/database.env", "mode": "0600", - "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n" + "template": "files/database.env" }, { "id": "net", @@ -126,7 +160,7 @@ "type": "file", "path": "${dir:state}/hostname.env", "mode": "0644", - "content": "KC_HOSTNAME=https://${bound:route:name}\n" + "template": "files/hostname.env" }, { "id": "server", @@ -161,9 +195,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", "merge": "json", - "takes-settings": [ + "takes-config": [ "url", "user", "realm", @@ -188,9 +222,21 @@ "MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json", - "MESH_KEYCLOAK_CONTAINER": "keycloak" + "MESH_KEYCLOAK_CONTAINER": "keycloak", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/modules/lab/files/lab.env b/modules/lab/files/lab.env new file mode 100644 index 00000000..35aa1a29 --- /dev/null +++ b/modules/lab/files/lab.env @@ -0,0 +1,3 @@ +MESH_LAB_FORGE=${config:forge} +MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json +MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret diff --git a/modules/lab/module.json b/modules/lab/module.json index c9c14770..66bb4b43 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -29,6 +29,14 @@ } ] }, + "config": { + "forge": { + "type": "string", + "description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "state", @@ -47,7 +55,7 @@ "type": "file", "path": "${dir:state}/lab.env", "mode": "0600", - "content": "MESH_LAB_FORGE=${setting:forge}\nMESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json\nMESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret\n" + "template": "files/lab.env" }, { "id": "git", @@ -108,6 +116,11 @@ "MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json", "MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/lab/test/base.test.ts b/modules/lab/test/base.test.ts new file mode 100644 index 00000000..93a6f8ec --- /dev/null +++ b/modules/lab/test/base.test.ts @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + BASE_IMAGE_ALIAS, BASE_MAX_AGE_DAYS, baseImageFrom, baseProblem, baseScript, buildingBase, cleanCredentials, credentialPath, + describeBase, launchRebuild, launchRun, listBaseBuilds, listRuns, readStatus, runRefusal, running, staleBase, + type BaseImage, +} from "../tools/runs.ts"; + +// Defends novox/hq issue 698: the lab's base image was 44 days old, a bed machine raised from it carried a +// package database the mirrors no longer served, and genesis-keeps-its-bus stopped on a 404 for +// postgresql-libs. Nothing said how old the image was, nothing refused it, and no mesh tool rebuilt it. + +const now = new Date("2026-10-14T01:00:00Z"); +const listed = (uploaded: string, alias = BASE_IMAGE_ALIAS) => JSON.stringify([ + { aliases: [{ name: "images:other" }], uploaded_at: "2026-10-13T00:00:00Z" }, + { aliases: [{ name: alias }], uploaded_at: uploaded, created_at: "2026-08-01T00:00:00Z" }, +]); + +test("the base image's age is read from when this machine made it", () => { + const b = baseImageFrom(listed("2026-08-31T09:00:00Z"), now); + assert.equal(b.exists, true); + assert.equal(b.built, "2026-08-31T09:00:00.000Z"); + assert.equal(b.ageDays, 43); + assert.match(describeBase(b), /built 2026-08-31T09:00:00.000Z \(43 day\(s\) ago; the bound is 14\)/); +}); + +test("an image older than the bound refuses a run, naming the tool that rebuilds it", () => { + const stale = baseImageFrom(listed("2026-08-31T09:00:00Z"), now); + const reason = runRefusal(stale); + assert.ok(reason, "a 43-day-old base image was not refused"); + assert.match(reason, /older than the lab's bound of 14 days/); + assert.match(reason, /lab_base \{"rebuild": true\}/); + assert.equal(staleBase(stale), reason); +}); + +test("an image within the bound runs", () => { + const fresh = baseImageFrom(listed(new Date(now.getTime() - BASE_MAX_AGE_DAYS * 86_400_000).toISOString()), now); + assert.equal(fresh.ageDays, BASE_MAX_AGE_DAYS); + assert.equal(runRefusal(fresh), undefined); +}); + +test("a missing image, or one whose age cannot be read, is said and not refused", () => { + const none = baseImageFrom(listed("2026-10-01T00:00:00Z", "something-else"), now); + assert.equal(none.exists, false); + assert.equal(runRefusal(none), undefined); + assert.match(describeBase(none), /is not on this machine; rebuild it with lab_base/); + const unread = baseImageFrom(undefined, now); + assert.equal(runRefusal(unread), undefined); + assert.match(describeBase(unread), /its age is not known/); +}); + +test("no run starts while the base image is rebuilt", () => { + const fresh = baseImageFrom(listed("2026-10-13T00:00:00Z"), now); + const reason = runRefusal(fresh, { id: "base-x", started: "2026-10-14T00:59:00Z" }); + assert.match(reason ?? "", /is being rebuilt \(base-x/); +}); + +test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => { + const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials"); + assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/); + assert.match(s, /src\/cli.ts base build/); + assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone"); +}); + +test("a rebuild is known by its status, and is not a run", () => { + const work = mkdtempSync(join(tmpdir(), "lab-base-")); + try { + mkdirSync(join(work, "base-1")); + writeFileSync(join(work, "base-1", "status.json"), JSON.stringify({ id: "base-1", state: "built", started: "2026-10-13T00:00:00Z" })); + mkdirSync(join(work, "base-2")); + // A build whose process is gone while it says building was lost, and holds nothing up. + writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: 2 ** 22 + 7 })); + assert.deepEqual(listBaseBuilds(work).map((b) => `${b.id} ${b.state}`), ["base-2 lost", "base-1 built"]); + assert.equal(buildingBase(work), undefined); + writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: process.pid })); + assert.equal(buildingBase(work)?.id, "base-2"); + assert.deepEqual(listRuns(work), []); + assert.ok(readFileSync(join(work, "base-1", "status.json"), "utf8")); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +// **A run and a rebuild asked together: one starts** (review 256). Each asks the package registry, which +// waits, and each refused while the other was going; asked at once, both passed their checks before either +// wrote its status. The registry is asked first now, and the check and the start are one step. +function aLab(): string { + return mkdtempSync(join(tmpdir(), "lab-launch-")); +} +const fresh: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-10-13T00:00:00.000Z", ageDays: 1 }; +const slowRegistry = () => new Promise<{ registry: string }>((ok) => setTimeout(() => ok({ registry: "https://forge.invalid/r/" }), 20)); +const launch = { registry: slowRegistry, base: () => fresh }; +function beginRun(work: string, id: string) { + return () => { + mkdirSync(join(work, id)); + const s = { id, state: "running" as const, started: new Date().toISOString(), tests: [], refs: {}, pid: process.pid }; + writeFileSync(join(work, id, "status.json"), JSON.stringify(s)); + return s; + }; +} +function beginBuild(work: string, id: string) { + return () => { + mkdirSync(join(work, id)); + const b = { id, state: "building" as const, started: new Date().toISOString(), pid: process.pid }; + writeFileSync(join(work, id, "status.json"), JSON.stringify(b)); + return b; + }; +} + +test("a run and a rebuild asked at once: exactly one starts", async () => { + for (const runFirst of [true, false]) { + const work = aLab(); + try { + // Both asked before either answers, in one order and then the other. + const ask = { run: () => launchRun(work, launch, beginRun(work, "run-a")), build: () => launchRebuild(work, launch, beginBuild(work, "base-a")) }; + const [r, b] = runFirst ? await Promise.all([ask.run(), ask.build()]) + : (await Promise.all([ask.build(), ask.run()])).reverse() as [Awaited>, Awaited>]; + assert.equal([r.started, b.started].filter(Boolean).length, 1, `both or neither started: run ${r.started}, rebuild ${b.started}`); + assert.equal(listRuns(work).length + listBaseBuilds(work).length, 1); + } finally { + rmSync(work, { recursive: true, force: true }); + } + } +}); + +test("two runs, or two rebuilds, asked at once: one of each starts", async () => { + const work = aLab(); + try { + const runs = await Promise.all([launchRun(work, launch, beginRun(work, "run-a")), launchRun(work, launch, beginRun(work, "run-b"))]); + assert.deepEqual(runs.map((r) => r.started).sort(), [false, true]); + const other = aLab(); + const builds = await Promise.all([launchRebuild(other, launch, beginBuild(other, "base-a")), launchRebuild(other, launch, beginBuild(other, "base-b"))]); + assert.deepEqual(builds.map((b) => b.started).sort(), [false, true]); + rmSync(other, { recursive: true, force: true }); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test("a run is refused on a stale base, and starts nothing", async () => { + const work = aLab(); + try { + const stale: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-08-31T09:00:00.000Z", ageDays: 43 }; + const r = await launchRun(work, { registry: slowRegistry, base: () => stale }, beginRun(work, "run-a")); + assert.equal(r.started, false); + assert.match(r.started ? "" : r.reason, /lab_base \{"rebuild": true\}/); + assert.deepEqual(listRuns(work), []); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test("a rebuild or a run that never recorded its process is lost, and holds nothing up", () => { + const work = aLab(); + try { + mkdirSync(join(work, "base-x")); + writeFileSync(join(work, "base-x", "status.json"), JSON.stringify({ id: "base-x", state: "building", started: "2026-10-14T00:00:00Z" })); + mkdirSync(join(work, "run-x")); + writeFileSync(join(work, "run-x", "status.json"), JSON.stringify({ id: "run-x", state: "running", started: "2026-10-14T00:00:00Z", tests: [], refs: {} })); + assert.equal(listBaseBuilds(work)[0]?.state, "lost"); + assert.equal(buildingBase(work), undefined); + assert.equal(readStatus(work, "run-x")?.state, "lost"); + assert.equal(running(work), undefined); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test("a rebuild's credential left behind is cleaned, and a running one's is kept", () => { + const work = aLab(); + try { + for (const id of ["base-old", "base-now", "run-old", "run-now"]) writeFileSync(credentialPath(work, id), "x"); + const removed = cleanCredentials(work, [credentialPath(work, "base-now"), credentialPath(work, "run-now")]).sort(); + assert.deepEqual(removed, [credentialPath(work, "base-old"), credentialPath(work, "run-old")].sort()); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test("lab_check's problem: a stale, missing or unread base image; a fresh one is none", () => { + assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false }) ?? "", /is not on this machine/); + assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false, problem: "incus could not list the lab's images" }) ?? "", /age is not known/); + assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: true, built: "x", ageDays: 30 }) ?? "", /older than the lab's bound/); + assert.equal(baseProblem(fresh), undefined); +}); + +// The tools use the gate and the check above (index.ts registers with the SDK when imported, so it is read). +test("lab_run and lab_base start through the gate, and lab_check names what is wrong with the base", () => { + const index = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8"); + assert.match(index, /await launchRun\(work,/); + assert.match(index, /await launchRebuild\(work,/); + assert.match(index, /const unfit = baseProblem\(base\);\s*if \(unfit\) problems\.push\(unfit\)/); + assert.match(index, /if \(args\.rebuild !== true\) return reading;/); +}); diff --git a/modules/lab/test/runs.test.ts b/modules/lab/test/runs.test.ts index 6be1063b..b2071208 100644 --- a/modules/lab/test/runs.test.ts +++ b/modules/lab/test/runs.test.ts @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts"; +import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts"; // Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and // mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its @@ -381,3 +381,126 @@ test("a forge setting that is not http(s) trusts no origin of its own", async () packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root); assert.match(viaRoot.problem ?? "", /neither/); }); + +// The runtime's env file keeps its text once, in files/ as its template (novox/hq ADR 0349 §2, issue 656): no +// file resource carries its text inline, and the build packs files/ into the one archive the controller reads +// the templates back from. +test("every file takes its text from a template in files/", () => { + const dir = join(import.meta.dirname, ".."); + const m = JSON.parse(readFileSync(join(dir, "module.json"), "utf8")) as { + resources: Record[]; build: { artifacts: { name: string; kind?: string; from?: string }[] }; + }; + for (const r of m.resources) { + assert.equal("content" in r, false, `${String(r.id)} keeps its text inline`); + if (r.type === "file") assert.ok(existsSync(join(dir, String(r.template))), `${String(r.id)} names ${String(r.template)}`); + } + const packs = m.build.artifacts.filter((a) => a.kind === "archive" && a.from === "files"); + assert.deepEqual(packs.map((a) => a.name), ["files"]); + const env = m.resources.find((r) => r.id === "runtime-env"); + assert.match(readFileSync(join(dir, String(env?.template)), "utf8"), /^MESH_LAB_FORGE=\$\{config:forge\}\n/); +}); + +// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0, +// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it. +const oldLog = [ + "STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.", + "\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:", + " \t\t\t\tMake sure you have all the referenced content and try again.", + "NotFound: content digest sha256:e2de96513ba9: not found", + "ℹ tests 1", "ℹ suites 0", "ℹ pass 0", "ℹ fail 0", "ℹ cancelled 0", "ℹ skipped 1", "ℹ todo 0", + "ℹ duration_ms 590254.163993", + "", + "recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, against mesh-lab 7ed7da1", +].join("\n"); + +// The same run, as the lab's suite says it once it names its skips (mesh-lab, issue 693). +const newLog = [ + "ℹ tests 1", "ℹ pass 0", "ℹ fail 0", "ℹ skipped 1", + "", + "recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, 1 skipped, against mesh-lab 7ed7da1", + "NOT PASSED: no test passed: 1 skipped, 1 of them as NOT REACHED", + "skipped: genesis adopts its bus — one bus (test/integration/genesis-keeps-its-bus.test.ts) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest", +].join("\n"); + +function anEndedRun(log: string, state: "passed" | "failed", exit: number): { work: string; id: string } { + const work = mkdtempSync(join(tmpdir(), "lab-runs-")); + const id = "run-2026-10-14T00-21-44-014Z"; + mkdirSync(join(work, id)); + writeFileSync(join(work, id, "run.log"), log); + writeFileSync(join(work, id, "status.json"), JSON.stringify({ + id, state, exit, started: "2026-10-14T00:21:44.014Z", ended: "2026-10-14T00:33:11.179Z", + tests: ["test/integration/genesis-keeps-its-bus.test.ts"], refs: {}, + })); + return { work, id }; +} + +test("a run whose only test skipped as NOT REACHED, from a lab of before, reads as failed with the reason", () => { + const { work, id } = anEndedRun(oldLog, "passed", 0); + const s = readStatus(work, id); + assert.equal(s?.state, "failed", JSON.stringify(s)); + assert.equal(s?.exit, 0); + assert.equal(s?.outcome?.passed, 0); + assert.equal(s?.outcome?.skipped, 1); + assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED"); + assert.match(s?.outcome?.skips[0]?.reason ?? "", /^ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:$/); + // Kept in the run's record, not only answered. + const kept = JSON.parse(readFileSync(join(work, id, "status.json"), "utf8")); + assert.equal(kept.state, "failed"); + assert.equal(kept.outcome.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED"); + rmSync(work, { recursive: true, force: true }); +}); + +test("the lab's own NOT PASSED and skipped lines are read, a test name's punctuation and all", () => { + const { work, id } = anEndedRun(newLog, "failed", 1); + const s = readStatus(work, id); + assert.equal(s?.state, "failed"); + assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED"); + assert.deepEqual(s?.outcome?.skips, [{ + file: "test/integration/genesis-keeps-its-bus.test.ts", + test: "genesis adopts its bus — one bus", + reason: "ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest", + }]); + rmSync(work, { recursive: true, force: true }); +}); + +test("a run that passed tests and skipped one for a declared reason stays passed, its skip listed", () => { + const log = [ + "✔ a module lands (12ms)", + "﹣ a builder builds (0.1ms) # set MESH_LAB_BUILDER to a built mesh-builder", + "ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1", + ].join("\n"); + const { work, id } = anEndedRun(log, "passed", 0); + const s = readStatus(work, id); + assert.equal(s?.state, "passed"); + assert.equal(s?.outcome?.notPassed, undefined); + assert.equal(s?.outcome?.skips[0]?.reason, "set MESH_LAB_BUILDER to a built mesh-builder"); + rmSync(work, { recursive: true, force: true }); +}); + +test("a run that ran no test at all is not passed", () => { + assert.equal(outcomeOf("ℹ tests 0\nℹ pass 0\nℹ fail 0\nℹ skipped 0\n").notPassed, "no test passed: none ran"); +}); + +// Review 254 on issue 693: a test's own output, printed before the suite reports, is not the suite's verdict. +test("a line a test printed that looks like the suite's is not read as the suite's", () => { + const log = [ + "NOT PASSED: a test printed this", + "skipped: a forged skip (test/integration/x.test.ts) # FORGED NOT REACHED", + "✔ a module lands (12ms)", + "ℹ tests 1", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 0", + "", + "recorded: test/integration/x.test.ts — 1 passed, 0 failed, 0 skipped, against mesh-lab 7ed7da1", + ].join("\n"); + const o = outcomeOf(log); + assert.equal(o.notPassed, undefined, JSON.stringify(o)); + assert.deepEqual(o.skips, []); +}); + +test("not reached is matched in any case", () => { + const log = [ + "✔ a module lands (12ms)", + "﹣ the broker step (0.1ms) # the broker step was not reached", + "ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1", + ].join("\n"); + assert.equal(outcomeOf(log).notPassed, "1 test(s) skipped as NOT REACHED: their subject was not reached"); +}); diff --git a/modules/lab/tools/index.ts b/modules/lab/tools/index.ts index 4511164a..63c7c41f 100644 --- a/modules/lab/tools/index.ts +++ b/modules/lab/tools/index.ts @@ -9,7 +9,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start, - stop, tail, writeCredential, + stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun, + launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem, } from "./runs.js"; export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { @@ -59,12 +60,18 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every // repository it clones answers on the forge, and every one it installs has a lock file where the run // installs from. - // A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use - // by a running run is not. - const busy = running(work); - const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined); - const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : ""; - const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...leftovers, ...problems].filter(Boolean).join("\n"); + // A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in + // use by a running run or rebuild is not. + const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id); + const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id))); + const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : ""; + // **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot + // be read is a problem here, though lab_run refuses only the first. + const base = readBaseImage(); + const unfit = baseProblem(base); + if (unfit) problems.push(unfit); + const output = [`${check.stdout}${check.stderr}`.trim(), describeBase(base), removed, ...leftovers, ...problems] + .filter(Boolean).join("\n"); return { ok: check.status === 0 && problems.length === 0, output }; }, }, @@ -96,11 +103,43 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { } const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r)); if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` }; - const busy = running(work); - if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy }; - const registry = await registryOf(forge); - return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry), - registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) }; + // **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698, + // review 256): the registry asked first, then the check and the start with nothing awaited between. + const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() }, + (registry) => start(work, forge, tests, refs, credentialOf(forge), registry)); + if (!launched.started) return launched; + return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry, + ...(launched.problem ? { problem: launched.problem } : {}) }; + }, + }, + { + name: "lab_base", + description: + "The lab's base image, which every bed machine with a container runtime is raised from: when it was built, its " + + `age, the bound lab_run refuses past (${BASE_MAX_AGE_DAYS} days), and the last rebuilds with the newest one's log. ` + + "Reads only, unless rebuild is true: then it rebuilds the image with mesh-lab's `base build` from the forge's " + + "main, in the background, and answers at once; ask again for the build's state and the image's new age.", + input: { + rebuild: { type: "boolean", description: "rebuild the image (default false: only read)" }, + }, + run: async (args) => { + const before = readBaseImage(); + const builds = listBaseBuilds(work).slice(0, 3); + const newest = builds[0]; + const reading = { + base: before, said: describeBase(before), bound: BASE_MAX_AGE_DAYS, + ...(baseProblem(before) ? { problem: baseProblem(before) } : {}), + builds, ...(newest ? { log: baseBuildLog(work, newest.id) } : {}), + }; + if (args.rebuild !== true) return reading; + const forge = forgeOf(); + if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"\"}" }; + // Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with + // nothing awaited between (review 256). + const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before }, + (registry) => startBaseBuild(work, forge, credentialOf(forge), registry)); + if (launched.started === false) return { ...reading, started: false, reason: launched.reason }; + return { ...reading, started: true, build: launched.job }; }, }, { diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index e3eb1b9d..c65bfb41 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -6,7 +6,7 @@ // and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers // for it, and says it was lost rather than pretending it is still going. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -223,16 +223,18 @@ trap ${shellQuote(`${rm}; exit 129`)} HUP`; } /** - * cleanCredentials removes every run's credential file left in the work directory but the one named — what - * a crash or a reboot left behind, which no trap could remove. Answers what it removed. + * cleanCredentials removes every run's and every base rebuild's credential file left in the work directory + * but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it + * removed. */ -export function cleanCredentials(work: string, keep?: string): string[] { +export function cleanCredentials(work: string, keep?: string | string[]): string[] { if (!existsSync(work)) return []; + const kept = new Set([keep ?? []].flat()); const removed: string[] = []; for (const name of readdirSync(work)) { - if (!/^\.run-.*\.git-credentials$/.test(name)) continue; + if (!/^\.(run|base)-.*\.git-credentials$/.test(name)) continue; const path = join(work, name); - if (path === keep) continue; + if (kept.has(path)) continue; rmSync(path, { force: true }); removed.push(path); } @@ -286,6 +288,117 @@ export interface RunStatus { commits?: Record; exit?: number; pid?: number; + /** What the suite said of its tests, read from the run's log once it ended (novox/hq issue 693). */ + outcome?: Outcome; +} + +/** One skipped test, its file and the reason it gave. */ +export interface Skip { + file: string; + test: string; + reason: string; +} + +/** + * What a run's suite said of its tests: its totals, each skip with its reason, and — when the run did not + * pass although its exit said 0 — why (`notPassed`). Totals the runner did not print are absent. + */ +export interface Outcome { + passed?: number; + failed?: number; + skipped?: number; + skips: Skip[]; + notPassed?: string; +} + +/** + * outcomeOf reads what a run's log says of its tests (novox/hq issue 693). + * + * **A run in which no test passed has proven nothing**, and the runner exits 0 when every test skipped: + * the genesis bed skipped its only test as "NOT REACHED", the suite printed "0 passed, 0 failed", and the + * lab recorded the run as passed. The lab's suite now says it itself — a "NOT PASSED: " line and a + * "skipped: () # " line per skip after its "recorded:" line, and an exit that is not + * 0 — and this reads those lines. For a run of a lab from before (a branch, or an old log), the same rule is + * read from the runner's own output: its summary totals, and its skip lines' first line of reason. + * + * The rule, the lab suite's (mesh-lab src/outcomes.ts): **not passed when no test passed**, or when a test + * skipped as NOT REACHED (its subject was not reached); a skip for another reason beside tests that passed + * leaves the run passed, its reason listed. + */ +export function outcomeOf(log: string): Outcome { + const plain = log.replace(/\u001b\[[0-9;]*m/g, ""); + const total = (word: string) => { + // The last summary in the log: the runner's own, once per run. + const all = [...plain.matchAll(new RegExp(`^\\s*(?:\u2139|#)\\s*${word}\\s+(\\d+)\\s*$`, "gm"))]; + const last = all[all.length - 1]; + return last ? Number(last[1]) : undefined; + }; + const outcome: Outcome = { skips: [] }; + const passed = total("pass"); + const failed = total("fail"); + const skipped = total("skipped"); + if (passed !== undefined) outcome.passed = passed; + if (failed !== undefined) outcome.failed = failed; + if (skipped !== undefined) outcome.skipped = skipped; + + // **The suite's own lines are read only after its last "recorded:" line** (novox/hq issue 693, review + // 254): a test prints what it likes before the suite reports, and a line of its that looked like the + // suite's — "NOT PASSED: …", "skipped: … # NOT REACHED" — would otherwise stand for the suite's verdict. + const recorded = [...plain.matchAll(/^recorded: .*$/gm)]; + const lastRecorded = recorded[recorded.length - 1]; + const suiteSaid = lastRecorded ? plain.slice((lastRecorded.index ?? 0) + lastRecorded[0].length) : ""; + + // The suite's own line, "skipped: () # ": the file in brackets, a .ts, parts it. + for (const m of suiteSaid.matchAll(/^skipped: (.*?) \(([^()]+\.ts)\) # (.*)$/gm)) { + outcome.skips.push({ file: m[2] ?? "", test: m[1] ?? "", reason: m[3] === "no reason given" ? "" : m[3] ?? "" }); + } + if (outcome.skips.length === 0) { + // A lab from before names no skip itself; its runner's spec line does: "﹣ (ms) # ". + for (const m of plain.matchAll(/^\s*\ufe63 (.*?) \([\d.]+m?s\) # (.*)$/gm)) { + const reason = (m[2] ?? "").trim(); + outcome.skips.push({ file: "", test: m[1] ?? "", reason: reason === "SKIP" ? "" : reason }); + } + } + + const said = suiteSaid.match(/^NOT PASSED: (.*)$/m); + if (said) { + outcome.notPassed = (said[1] ?? "").trim(); + } else if ((failed ?? 0) === 0) { + // In any case: "not reached" says the same as "NOT REACHED". + const unreached = outcome.skips.filter((s) => s.reason.toUpperCase().includes("NOT REACHED")).length; + if (passed === 0) { + outcome.notPassed = outcome.skips.length > 0 || (skipped ?? 0) > 0 + ? `no test passed: ${skipped ?? outcome.skips.length} skipped${unreached > 0 ? `, ${unreached} of them as NOT REACHED` : ""}` + : "no test passed: none ran"; + } else if (unreached > 0) { + outcome.notPassed = `${unreached} test(s) skipped as NOT REACHED: their subject was not reached`; + } + } + return outcome; +} + +/** + * settled is a run that ended, with its outcome read from its log: a run whose exit said 0 and whose suite + * proved nothing reads as failed, with why (novox/hq issue 693). Read once and kept in its status, so the + * record says it too; a run still going, stopped or lost is left as it is. + */ +export function settled(work: string, s: RunStatus): RunStatus { + if (s.outcome || !s.ended || (s.state !== "passed" && s.state !== "failed")) return s; + let log: string; + try { + log = readFileSync(join(runDir(work, s.id), "run.log"), "utf8"); + } catch { + return s; + } + const outcome = outcomeOf(log); + const next: RunStatus = { ...s, outcome }; + if (next.state === "passed" && outcome.notPassed) next.state = "failed"; + try { + writeFileSync(statusPath(work, s.id), JSON.stringify(next, null, 2)); + } catch { + // Read again next time: what lab_status answers is the same either way. + } + return next; } export function runDir(work: string, id: string): string { @@ -300,16 +413,23 @@ export function readStatus(work: string, id: string): RunStatus | undefined { try { const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus; // A run whose process is gone while its status still says it is going was lost — the runtime or - // the machine restarted under it. Said, rather than left reading as running for ever. - if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) { + // the machine restarted under it. Said, rather than left reading as running for ever. One that never + // recorded a process (the runtime died between writing its status and starting it) is lost too: it + // would otherwise hold up every run after it (review 256). + if (!["passed", "failed", "stopped", "lost"].includes(s.state) && gone(s.pid)) { s.state = "lost"; } - return s; + return settled(work, s); } catch { return undefined; } } +/** gone is whether a job's process is not running: none recorded, or none by that id. */ +function gone(pid: number | undefined): boolean { + return !pid || !alive(pid); +} + function alive(pid: number): boolean { try { process.kill(pid, 0); @@ -446,3 +566,238 @@ export function tail(work: string, id: string, lines: number): string { return ""; } } + +// ---- the base image (novox/hq issue 698) +// +// Every bed machine that needs a container runtime is a clone of one base image, `mesh-lab/base`, which +// mesh-lab's `base build` makes from the distribution's current image. A machine raised from it carries the +// package database the image was built with. On a rolling distribution the mirrors stop serving what an old +// database names within weeks, and a bed that installs a package then fails on a 404 that reads as a mirror +// outage (novox/hq issue 205; seen 2026-10-14 when genesis-keeps-its-bus installed postgresql-libs from a +// database six weeks old). So the lab names the image's age wherever it is asked, refuses a run on an image +// older than the bound, and rebuilds the image when asked to. +// +// **The bound is 14 days** (BASE_MAX_AGE_DAYS). Arch's mirrors keep a package version for as long as it is +// current and drop it soon after it is replaced; an image a fortnight old has reliably installed what beds +// install, and the one that failed was 44 days old. A rebuild takes minutes, so a short bound costs little; +// a run refused is cheaper than a bed that fails an hour in for a reason that is not the change under test. + +/** The image every bed machine with a runtime is raised from, as mesh-lab names it (src/lifecycle/place.ts). */ +export const BASE_IMAGE_ALIAS = "mesh-lab/base"; + +/** How old the base image may be, in days, before a run is refused (see the head of this file). */ +export const BASE_MAX_AGE_DAYS = 14; + +const DAY = 24 * 60 * 60 * 1000; + +export interface BaseImage { + alias: string; + /** Whether the image is there at all. */ + exists: boolean; + /** When this machine made it, ISO. */ + built?: string; + /** Its age in whole days. */ + ageDays?: number; + /** Why its age could not be read, when it could not. */ + problem?: string; +} + +/** + * baseImageFrom reads `incus image list --format json`: the image whose aliases hold the base's + * name, and when it was made here (its upload, which a publish sets). + */ +export function baseImageFrom(listed: string | undefined, now: Date): BaseImage { + const alias = BASE_IMAGE_ALIAS; + if (listed === undefined) return { alias, exists: false, problem: "incus could not list the lab's images" }; + let images: Array<{ aliases?: Array<{ name?: string }>; uploaded_at?: string; created_at?: string }>; + try { + images = JSON.parse(listed); + } catch { + return { alias, exists: false, problem: "incus listed the lab's images as something that is not JSON" }; + } + const image = (Array.isArray(images) ? images : []).find((i) => (i.aliases ?? []).some((a) => a.name === alias)); + if (!image) return { alias, exists: false }; + const made = [image.uploaded_at, image.created_at].map((t) => (t ? Date.parse(t) : NaN)) + .find((t) => Number.isFinite(t) && t > 0); + if (made === undefined) return { alias, exists: true, problem: "incus gives no time the image was made" }; + return { alias, exists: true, built: new Date(made).toISOString(), ageDays: Math.floor((now.getTime() - made) / DAY) }; +} + +/** readBaseImage asks incus, on this machine, for the base image. */ +export function readBaseImage(now = new Date()): BaseImage { + const listed = spawnSync("incus", ["image", "list", BASE_IMAGE_ALIAS, "--format", "json"], { encoding: "utf8" }); + return baseImageFrom(listed.status === 0 ? listed.stdout : undefined, now); +} + +/** The base image's age in one sentence, as lab_check and lab_run say it. */ +export function describeBase(b: BaseImage): string { + if (b.problem) return `the base image ${b.alias}: its age is not known: ${b.problem}`; + if (!b.exists) return `the base image ${b.alias} is not on this machine; rebuild it with lab_base {"rebuild": true}`; + return `the base image ${b.alias} was built ${b.built} (${b.ageDays} day(s) ago; the bound is ${BASE_MAX_AGE_DAYS})`; +} + +/** + * staleBase is why a run is refused for its base image, or undefined when it is not: an image older than the + * bound. An image that is missing or whose age cannot be read is not refused here — a bed that needs it says + * so itself, and a bed that does not need it runs. + */ +export function staleBase(b: BaseImage): string | undefined { + if (!b.exists || b.ageDays === undefined || b.ageDays <= BASE_MAX_AGE_DAYS) return undefined; + return `the base image ${b.alias} was built ${b.built}, ${b.ageDays} days ago, older than the lab's bound of ` + + `${BASE_MAX_AGE_DAYS} days: a bed machine raised from it carries a package database the mirrors no longer serve ` + + `(novox/hq issues 205, 698). Rebuild it with lab_base {"rebuild": true}, then run again`; +} + +/** + * baseProblem is what lab_check and lab_base say is wrong with the base image, or undefined: past the bound, not + * on this machine, or of an age that cannot be read (review 256). Only the first refuses a run. + */ +export function baseProblem(b: BaseImage): string | undefined { + if (b.problem || !b.exists) return describeBase(b); + return staleBase(b); +} + +/** + * runRefusal is why lab_run starts no run for the base image, or undefined: a rebuild still going, which + * replaces the image a bed raises its machines from, or an image older than the bound. + */ +export function runRefusal(base: BaseImage, rebuilding?: { id: string; started: string }): string | undefined { + if (rebuilding) return `the base image is being rebuilt (${rebuilding.id}, since ${rebuilding.started}); run once lab_base says it is built`; + return staleBase(base); +} + +// ---- its rebuild: mesh-lab's `base build`, in the background, as a run is. + +export interface BaseBuild { + id: string; + state: "building" | "built" | "failed" | "lost"; + started: string; + ended?: string; + exit?: number; + pid?: number; +} + +function buildStatusPath(work: string, id: string): string { + return join(work, id, "status.json"); +} + +/** The base builds, newest first. A build whose process is gone while it says building was lost. */ +export function listBaseBuilds(work: string): BaseBuild[] { + if (!existsSync(work)) return []; + const builds: BaseBuild[] = []; + for (const id of readdirSync(work).filter((d) => d.startsWith("base-"))) { + try { + const b = JSON.parse(readFileSync(buildStatusPath(work, id), "utf8")) as BaseBuild; + if (b.state === "building" && gone(b.pid)) b.state = "lost"; + builds.push(b); + } catch { + // A directory without a status is not a build. + } + } + return builds.sort((a, b) => b.started.localeCompare(a.started)); +} + +/** The base build still going, if any. */ +export function buildingBase(work: string): BaseBuild | undefined { + return listBaseBuilds(work).find((b) => b.state === "building"); +} + +/** The last lines of a base build's log. */ +export function baseBuildLog(work: string, id: string, lines = 40): string { + try { + return readFileSync(join(work, id, "build.log"), "utf8").split("\n").slice(-Math.max(1, lines)).join("\n"); + } catch { + return ""; + } +} + +/** + * baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it, + * then its `base build`, which replaces the image and reads back that it works before publishing it. + */ +export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string { + const lab = join(dir, "mesh-lab"); + return `set -euo pipefail +export GIT_TERMINAL_PROMPT=0 +${credentialGuard(credential)} +cd ${shellQuote(dir)} +git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)} +${credential ? `rm -f ${shellQuote(credential)}` : ""} +echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)" +printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))} +export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))} +${installStep(lab, false)} +cd ${shellQuote(lab)} +node --experimental-strip-types src/cli.ts base build +`; +} + +/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */ +export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild { + const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const dir = join(work, id); + mkdirSync(dir, { recursive: true }); + const build: BaseBuild = { id, state: "building", started: new Date().toISOString() }; + writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2)); + // What a crashed or rebooted run or rebuild left behind goes before this one writes its own. + cleanCredentials(work); + const credentials = writeCredential(credentialPath(work, id), credential); + writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 }); + const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$? +node -e ${shellQuote( + `const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`, + )} "$code" +rm -f ${shellQuote(credentialPath(work, id))} +rm -rf ${shellQuote(join(dir, "mesh-lab"))}`; + const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" }); + child.unref(); + build.pid = child.pid; + writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2)); + return build; +} + +// ---- starting a run or a rebuild: one job on the lab's machine at a time (review 256). +// +// **Everything that waits is done first; the check and the start are one step.** A run and a rebuild each +// refused while the other was going, but each asked the package registry — an await — between its check and +// writing its status, so a run and a rebuild asked together could both pass their checks and both start: a +// bed raising machines from an image being replaced under it. Here the registry is asked first, and nothing +// is awaited between reading what is going and writing the new job's status, so in the one runtime that +// serves the lab's tools no other call runs in between. + +export interface Launch { + /** The address a run or a rebuild names the package registry by, asked of the forge (npmRegistry). */ + registry: () => Promise<{ registry: string; problem?: string }>; + /** The base image as it is now. */ + base: () => BaseImage; +} + +export type Launched = { started: true; registry: string; problem?: string; base: BaseImage; job: T } + | { started: false; reason: string; base: BaseImage }; + +/** launchRun starts a run unless a run or a rebuild is going, or the base image is past its bound. */ +export async function launchRun(work: string, launch: Launch, begin: (registry: string) => RunStatus): + Promise> { + const registry = await launch.registry(); + // From here to begin(): nothing awaited. + const base = launch.base(); + const busy = running(work); + if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, base }; + const refused = runRefusal(base, buildingBase(work)); + if (refused) return { started: false, reason: refused, base }; + return { started: true, ...registry, base, job: begin(registry.registry) }; +} + +/** launchRebuild starts a rebuild of the base image unless a run or another rebuild is going. */ +export async function launchRebuild(work: string, launch: Launch, begin: (registry: string) => BaseBuild): + Promise> { + const registry = await launch.registry(); + // From here to begin(): nothing awaited. + const base = launch.base(); + const rebuilding = buildingBase(work); + if (rebuilding) return { started: false, reason: `${rebuilding.id} is still rebuilding it`, base }; + // A run raises its machines from the image; replacing it under one would change what the run tests. + const busy = running(work); + if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}, and raises its machines from this image`, base }; + return { started: true, ...registry, base, job: begin(registry.registry) }; +} diff --git a/modules/lemurs/README.md b/modules/lemurs/README.md index 1df59e03..ebe732b3 100644 --- a/modules/lemurs/README.md +++ b/modules/lemurs/README.md @@ -17,7 +17,7 @@ The login manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2 | path | class | what | |---|---|---| -| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`config/config.toml`](config/config.toml), carried whole in the manifest | +| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`files/config.toml`](files/config.toml), the file's template | | `/etc/lemurs/xsessions/`, `/etc/lemurs/wayland-sessions/` | owned, empty | where the configuration points lemurs for desktop entries, so none is offered | **Sessions are drop-ins.** The sessions offered are the executable files session modules place in diff --git a/modules/lemurs/cmd/lemurs-tools/lemurs_test.go b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go index c9580cc8..fd8d1362 100644 --- a/modules/lemurs/cmd/lemurs-tools/lemurs_test.go +++ b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go @@ -73,7 +73,7 @@ func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) { } func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) { - raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml")) + raw, err := os.ReadFile(filepath.Join("..", "..", "files", "config.toml")) if err != nil { t.Fatal(err) } diff --git a/modules/lemurs/cmd/lemurs-tools/manifest_test.go b/modules/lemurs/cmd/lemurs-tools/manifest_test.go index 38764694..98a24914 100644 --- a/modules/lemurs/cmd/lemurs-tools/manifest_test.go +++ b/modules/lemurs/cmd/lemurs-tools/manifest_test.go @@ -93,11 +93,12 @@ func TestTheOfficialPackageAndItsServiceEnabledButNeverRestarted(t *testing.T) { func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t *testing.T) { m := readManifest(t) c := resource(t, m, "config") - raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config.toml")) - if c["path"] != "/etc/lemurs/config.toml" || c["content"] != string(raw) || c["into"] != nil { - t.Fatal("the manifest carries config/config.toml whole, as an owned file") + raw, _ := os.ReadFile(filepath.Join("..", "..", "files", "config.toml")) + if _, inline := c["content"]; inline || c["template"] != "files/config.toml" || len(raw) == 0 || + c["path"] != "/etc/lemurs/config.toml" || c["into"] != nil { + t.Fatal("the manifest takes files/config.toml whole as its template, as an owned file") } - text := c["content"].(string) + text := string(raw) for _, want := range []string{"[x11]", "[wayland]", `xsetup_path = "/etc/lemurs/xsetup.sh"`, `scripts_path = "/etc/lemurs/wms"`, `scripts_path = "/etc/lemurs/wayland"`, `xsessions_path = "/etc/lemurs/xsessions"`, `wayland_sessions_path = "/etc/lemurs/wayland-sessions"`, `tty = 2`, `x11_display = ":1"`, `remember = true`} { @@ -116,3 +117,31 @@ func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t } } } + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + m := readManifest(t) + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if name, ok := r["template"].(string); ok { + if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil { + t.Errorf("resource %v names %s: %v", r["id"], name, err) + } + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/lemurs/config/config.toml b/modules/lemurs/files/config.toml similarity index 100% rename from modules/lemurs/config/config.toml rename to modules/lemurs/files/config.toml diff --git a/modules/lemurs/files/xserver b/modules/lemurs/files/xserver new file mode 100755 index 00000000..5908f228 --- /dev/null +++ b/modules/lemurs/files/xserver @@ -0,0 +1,8 @@ +#!/bin/sh +# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push. +# +# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the +# resource database. The session's start merges the X resources with xrdb before any long-lived +# client is connected, so the server reset twice in the four seconds after a login and threw the +# resources away: xterm fell back to the bitmap "fixed" font, without the mesh's face and colours. +exec /usr/bin/X "$@" -noreset diff --git a/modules/lemurs/module.json b/modules/lemurs/module.json index e759bcc1..abaf8b11 100644 --- a/modules/lemurs/module.json +++ b/modules/lemurs/module.json @@ -30,14 +30,14 @@ "type": "file", "path": "/etc/lemurs/xserver", "mode": "0755", - "content": "#!/bin/sh\n# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push.\n#\n# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the\n# resource database. The session's start merges the X resources with xrdb before any long-lived\n# client is connected, so the server reset twice in the four seconds after a login and threw the\n# resources away: xterm fell back to the bitmap \"fixed\" font, without the mesh's face and colours.\nexec /usr/bin/X \"$@\" -noreset\n" + "template": "files/xserver" }, { "id": "config", "type": "file", "path": "/etc/lemurs/config.toml", "mode": "0644", - "content": "# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced\n# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with\n# every option, as lemurs requires); what the mesh changes from it is marked \"mesh:\".\n#\n# The sessions offered are the executable files other modules place in the two scripts directories\n# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there\n# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are\n# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is\n# where the account's environment, the X resources and every module's session lines are.\n#\n# Lemurs configuration file.\n# Contains all the customization options of lemurs.\n#\n# Note: that as of now you need to have all options in the selected\n# configuration file. Otherwise Lemurs will not work.\n#\n# Colors:\n# ---------\n# There is a list of predefined colors. These include:\n# - black\n# - white\n# - (dark) gray\n# - (light) red\n# - (light) blue\n# - (light) green\n# - (light) magenta\n# - (light) cyan\n# - (light) yellow\n# - orange\n#\n# You can also utilize custom colors with hex color codes.\n# \"#87CEEB\" will create a Sky Blue color.\n#\n# Note: If the color wasn't recognized, it will default to white.\n# ---------\n#\n# Modifiers:\n# ---------\n# There is a number of modifiers you can use. These can be combined by\n# delimiting them with a comma (e.g. \"bold,italic\"). The modifiers are:\n# - bold\n# - dim\n# - italic\n# - underlined\n# - reverse\n# - crossed out\n# - hidden\n# ---------\n#\n\n# The tty which contains lemurs. This has to be mirrored in the lemurs.service\ntty = 2\n\n# Where to log the main lemurs control flow.\nmain_log_path = \"/var/log/lemurs.log\"\n\n# Where to log to for the client. The Client is the Desktop Environment or\n# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.\nclient_log_path = \"/var/log/lemurs.client.log\"\n\n# At which point to point the cache. If you want to disable the cache globally\n# you can use `/dev/null`.\ncache_path = \"/var/cache/lemurs\"\n\n# Disable all logging. This is overwritten by the `--no-log` flag.\ndo_log = true\n\n# The PAM service that should be used to login\npam_service = \"lemurs\"\n\n# Path to system shell that gets used to execute linux commands. In almost all\n# cases, this should refer to a bash shell.\nsystem_shell = \"/bin/sh\"\n\n# Initial state of the `PATH` environment variable.\ninitial_path = \"/usr/local/sbin:/usr/local/bin:/usr/bin\"\n\n# The type flag that will be appended to the shell that calls the session\n# environment. This may depend on your shell. Options:\n# - 'none'. Disables calling a login shell\n# - 'short'. Produces the `-l` flag. Supported by most shells.\n# - 'long'. This produces the `--login` flag and is suited for bash and zsh.\nshell_login_flag = \"short\"\n\n# Focus behaviour of fields when Lemurs is initially started\n#\n# Possible values:\n# - default: Initially focus on first non-cached value\n# - no-focus: No initial focus\n# - environment: Initially focus on the environment selector\n# - username: Initially focus on the username field\n# - password: Initially focus on the password field\nfocus_behaviour = \"default\"\n\n# General settings for background style\n[background]\n\n# Control whether to render background widget or not\nshow_background = false\n\n[background.style]\n# Allow to set the default background color for the login shell\ncolor = \"black\"\n# Settings for the background block's borders\nshow_border = true\nborder_color = \"white\"\n\n[power_controls]\n# The margin between hints\nhint_margin = 2\n\n# There are no additional entries by default\nentries = []\n\n# Example\n# Reboot to another os option\n#[[power_controls.entries]]\n## The text in the top-left to display how to reboot.\n#hint = \"Reboot to OS\"\n#\n## The color and modifiers of the hint in the top-left corner\n#hint_color = \"dark gray\"\n#hint_modifiers = \"\"\n#\n## The key used to reboot. Possibilities are F1 to F12.\n#key = \"F3\"\n## The command that is executed when the key is pressed\n#cmd = \"efibootmgr -n0 && systemctl reboot -l\"\n\n\n# If you want to remove the base_entries\n# base_entries = []\n\n# Shutdown option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to shutdown.\nhint = \"Shutdown\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to shutdown. Possibilities are F1 to F12.\nkey = \"F1\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl poweroff -l\"\n\n# Reboot option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to reboot.\nhint = \"Reboot\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to reboot. Possibilities are F1 to F12.\nkey = \"F2\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl reboot -l\"\n\n# Setting for the selector of the desktop environment you are using.\n[environment_switcher]\n# Terms:\n# ---------\n# Movers: indicators which show which direction one can move whilst selecting\n# the desktop environment\n# Selected: The currently selected desktop environment.\n# Neighbours: The adjacent desktop environment to the one current selected\n#\n# Visualisation:\n#\n# < i3 bspwm awesome >\n#\n# ^ ^ ^ ^ ^\n# | | | | |\n# mover | selected | mover\n# | |\n# neighbour neighbour\n# ---------\n#\n\n# Control the visibility of the switcher\n# Options:\n# - \"visible\" - Always show the switcher [default]\n# - \"hidden\" - Always hide the switcher\n# - [key] - F1-F12 to be able to toggle the visibility\n# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.\nswitcher_visibility = \"F3\"\n\n# The text in the top-left to display how to toggle the switcher. The text\n# '%key%' will be replaced with the switcher_visibility key. This is not shown\n# if switcher_visibility is set to \"visible\" or \"hidden\".\ntoggle_hint = \"Switcher %key%\"\n\n# The color and modifiers of the hint in the top-left corner\ntoggle_hint_color = \"dark gray\"\ntoggle_hint_modifiers = \"\"\n\n\n# Show an option for the TTY shell when logging in as one of the environments.\n# NOTE: it is always shown when no viable options are found.\ninclude_tty_shell = false\n\n# Remember the selected environment after logging in for the next time\nremember = true\n\n# Enables showing the movers\nshow_movers = true\n\n# Mover's color and modifiers whilst the selector is unfocused\nmover_color = \"dark gray\"\nmover_modifiers = \"\"\n\n# Mover's color and modifiers whilst the selector is focused\nmover_color_focused = \"orange\"\nmover_modifiers_focused = \"bold\"\n\n# The characters used to display the movers. Suggestions are:\n# - \"<\" \">\"\n# - \"<-\" \"->\"\n# - \"<<\" \">>\"\n# - \"[\" \"]\"\nleft_mover = \"<\"\nright_mover = \">\"\n\n# The margin between the movers and the neighbours or selected (depending on\n# `show_neighbours`)\nmover_margin = 1\n\n# Enables showing the neighbours\nshow_neighbours = true\n\n# Neighbours' color and modifiers whilst the selector is unfocused\nneighbour_color = \"dark gray\"\nneighbour_modifiers = \"\"\n\n# Neighbours' color and modifiers whilst the selector is focused\nneighbour_color_focused = \"gray\"\nneighbour_modifiers_focused = \"\"\n\n# Margin between neighbours and selected\nneighbour_margin = 1\n\n# Selected's color and modifiers whilst the selector is unfocused\nselected_color = \"gray\"\nselected_modifiers = \"underlined\"\n\n# Selected's color and modifiers whilst the selector is focused\nselected_color_focused = \"white\"\nselected_modifiers_focused = \"bold\"\n\n# The length of the name of the desktop environment which is displayed.\nmax_display_length = 8\n\n# The text used when no desktop environments are available\nno_envs_text = \"No environments...\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is unfocused\nno_envs_color = \"white\"\nno_envs_modifiers = \"\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is focused\nno_envs_color_focused = \"red\"\nno_envs_modifiers_focused = \"\"\n\n[username_field]\n\n# Remember the username for the next time after a successful login attempt.\nremember = true\n\n[username_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Login\"\n\n# The title's color and modifiers whilst the username field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the username field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the username field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the username field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the username field\nuse_max_width = true\n# The constraint of the username field's width\nmax_width = 48\n\n[password_field]\n\n# The character used for replacement when typing a password. Leave empty for no\n# feedback.\n# Note: Only one character is accepted.\ncontent_replacement_character = \"*\"\n\n[password_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Password\"\n\n# The title's color and modifiers whilst the password field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the password field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the password field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the password field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the password field\nuse_max_width = true\n# The constraint of the password field's width\nmax_width = 48\n\n[x11]\n# Where to log to for the XServer.\nxserver_log_path = \"/var/log/lemurs.xorg.log\"\n\n# The value of the `DISPLAY` environment variable for X11 sessions\nx11_display = \":1\"\n\n# How many seconds to give the X server to start. To make it infinitely, put it\n# to 0.\nxserver_timeout_secs = 60\n\n# Where to find the X11 server binary\n# mesh: the server through the wrapper below, which adds -noreset.\nxserver_path = \"/etc/lemurs/xserver\"\n\n# Where to find the X11 xauth binary\nxauth_path = \"/usr/bin/xauth\"\n\n# Path to the directory where the startup scripts for the X11 sessions are found\nscripts_path = \"/etc/lemurs/wms\"\n\n# Path to the xsetup script that is needed for the environment setup of the\n# window manager.\nxsetup_path = \"/etc/lemurs/xsetup.sh\"\n\n# The directory to use for desktop entries X11 sessions.\n# mesh: an empty directory of the module's own, so no package's desktop entry is offered.\nxsessions_path = \"/etc/lemurs/xsessions\"\n\n[wayland]\n# Path to the directory where the startup scripts for the Wayland sessions are\n# found\nscripts_path = \"/etc/lemurs/wayland\"\n\n# The directory to use for desktop entries wayland sessions.\n# mesh: likewise for Wayland.\nwayland_sessions_path = \"/etc/lemurs/wayland-sessions\"\n" + "template": "files/config.toml" }, { "id": "xsessions", @@ -71,6 +71,11 @@ "loads": [ "lemurs-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/letta/files/config.json b/modules/letta/files/config.json new file mode 100644 index 00000000..8ed994da --- /dev/null +++ b/modules/letta/files/config.json @@ -0,0 +1,3 @@ +{ + "password": "${secret:server-password}" +} diff --git a/modules/letta/files/pgpass b/modules/letta/files/pgpass new file mode 100644 index 00000000..cd41b665 --- /dev/null +++ b/modules/letta/files/pgpass @@ -0,0 +1 @@ +*:*:*:${bound:postgres-database:as}:${secret:postgres-database} diff --git a/modules/letta/files/server.env b/modules/letta/files/server.env new file mode 100644 index 00000000..fe2acb9e --- /dev/null +++ b/modules/letta/files/server.env @@ -0,0 +1,6 @@ +LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +PGPASSFILE=/run/secrets/pgpass +LETTA_SERVER_PASSWORD=${secret:server-password} +OPENAI_API_KEY=${secret:openai-api-key} +SECURE=true +TZ=Europe/Brussels diff --git a/modules/letta/files/start.sh b/modules/letta/files/start.sh new file mode 100644 index 00000000..99555927 --- /dev/null +++ b/modules/letta/files/start.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push. +# +# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268): +# letta/server/rest_api/app.py prints its server password when it starts in secure mode; +# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole. +# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server +# password is rewritten before the server starts. Either one failing refuses the start: a letta +# that does not start says why here, and one that leaks says nothing. +set -e +app=/app/letta/server/rest_api/app.py +sed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' "$app" +if grep -q 'print(.*random_password' "$app"; then + echo "letta: $app still prints the server password; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +case "$LETTA_PG_URI" in + *://*:*@*) + echo "letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)" >&2 + exit 1 + ;; +esac +exec ./letta/server/startup.sh diff --git a/modules/letta/module.json b/modules/letta/module.json index 4174b99c..dbcc5590 100644 --- a/modules/letta/module.json +++ b/modules/letta/module.json @@ -47,6 +47,14 @@ "why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's" } ], + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Letta at, as a URL; unset, the code falls back to its environment, else this machine's Letta on port 8283.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -65,21 +73,21 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nPGPASSFILE=/run/secrets/pgpass\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n" + "template": "files/server.env" }, { "id": "pgpass", "type": "file", "path": "${dir:state}/pgpass", "mode": "0600", - "content": "*:*:*:${bound:postgres-database:as}:${secret:postgres-database}\n" + "template": "files/pgpass" }, { "id": "start", "type": "file", "path": "${dir:state}/start.sh", "mode": "0644", - "content": "#!/bin/sh\n# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push.\n#\n# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268):\n# letta/server/rest_api/app.py prints its server password when it starts in secure mode;\n# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole.\n# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server\n# password is rewritten before the server starts. Either one failing refuses the start: a letta\n# that does not start says why here, and one that leaks says nothing.\nset -e\napp=/app/letta/server/rest_api/app.py\nsed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' \"$app\"\nif grep -q 'print(.*random_password' \"$app\"; then\n echo \"letta: $app still prints the server password; not starting (novox/hq issue 268)\" >&2\n exit 1\nfi\ncase \"$LETTA_PG_URI\" in\n *://*:*@*)\n echo \"letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)\" >&2\n exit 1\n ;;\nesac\nexec ./letta/server/startup.sh\n" + "template": "files/start.sh" }, { "id": "net", @@ -113,8 +121,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"password\": \"${secret:server-password}\"\n}\n", + "template": "files/config.json", "merge": "json", + "takes-config": ["url"], "protected": ["password"] } ], @@ -134,6 +143,11 @@ "MESH_LETTA_URL": "http://127.0.0.1:${port:8283}", "MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/local-model-consumer/files/openai.env b/modules/local-model-consumer/files/openai.env new file mode 100644 index 00000000..9aca3962 --- /dev/null +++ b/modules/local-model-consumer/files/openai.env @@ -0,0 +1,3 @@ +OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1 +OPENAI_MODEL=${bound:model-access:model} +OPENAI_API_KEY=local diff --git a/modules/local-model-consumer/module.json b/modules/local-model-consumer/module.json index e90fbf5c..6fdb90b5 100644 --- a/modules/local-model-consumer/module.json +++ b/modules/local-model-consumer/module.json @@ -25,7 +25,16 @@ "type": "file", "path": "${dir:config}/openai.env", "mode": "0600", - "content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\nOPENAI_MODEL=${bound:model-access:model}\nOPENAI_API_KEY=local\n" + "template": "files/openai.env" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/localization/cmd/localization-tools/manifest_test.go b/modules/localization/cmd/localization-tools/manifest_test.go index 27edf2f1..2c6ab5b6 100644 --- a/modules/localization/cmd/localization-tools/manifest_test.go +++ b/modules/localization/cmd/localization-tools/manifest_test.go @@ -15,16 +15,16 @@ func TestTheFilesSayWhatTheToolsCompareAgainst(t *testing.T) { t.Fatalf("%s %s", m.Module, m.Version) } locale := m.resource(t, "locale") - if locale["path"] != "/etc/locale.conf" || locale["into"] != nil || !strings.Contains(locale["content"].(string), "\nLANG="+MeshLang+"\n") { + if locale["path"] != "/etc/locale.conf" || locale["into"] != nil || !strings.Contains(templateText(t, locale), "\nLANG="+MeshLang+"\n") { t.Fatalf("locale: %v", locale) } keymap := m.resource(t, "keymap") - if keymap["path"] != "/etc/vconsole.conf" || !strings.Contains(keymap["content"].(string), "\nKEYMAP="+MeshKeymap+"\n") { + if keymap["path"] != "/etc/vconsole.conf" || !strings.Contains(templateText(t, keymap), "\nKEYMAP="+MeshKeymap+"\n") { t.Fatalf("keymap: %v", keymap) } for _, r := range []resource{locale, keymap} { var settings []string - for _, l := range strings.Split(r["content"].(string), "\n") { + for _, l := range strings.Split(templateText(t, r), "\n") { if l != "" && !strings.HasPrefix(l, "#") { settings = append(settings, l) } diff --git a/modules/localization/cmd/localization-tools/templates_test.go b/modules/localization/cmd/localization-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/localization/cmd/localization-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/localization/files/locale.conf b/modules/localization/files/locale.conf new file mode 100644 index 00000000..fe405b50 --- /dev/null +++ b/modules/localization/files/locale.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every +# push; an edit here is overwritten. Read at the next login. +LANG=en_US.UTF-8 diff --git a/modules/localization/files/vconsole.conf b/modules/localization/files/vconsole.conf new file mode 100644 index 00000000..88d96f68 --- /dev/null +++ b/modules/localization/files/vconsole.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every +# push; an edit here is overwritten. Read at the next boot. +KEYMAP=us diff --git a/modules/localization/module.json b/modules/localization/module.json index 020b9026..0974ed38 100644 --- a/modules/localization/module.json +++ b/modules/localization/module.json @@ -16,14 +16,14 @@ "type": "file", "path": "/etc/locale.conf", "mode": "0644", - "content": "# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every\n# push; an edit here is overwritten. Read at the next login.\nLANG=en_US.UTF-8\n" + "template": "files/locale.conf" }, { "id": "keymap", "type": "file", "path": "/etc/vconsole.conf", "mode": "0644", - "content": "# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every\n# push; an edit here is overwritten. Read at the next boot.\nKEYMAP=us\n" + "template": "files/vconsole.conf" }, { "id": "time-zone", @@ -50,6 +50,11 @@ "loads": [ "localization-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/logrotate/cmd/logrotate-tools/logrotate_test.go b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go index 94b7b76c..6e0c3464 100644 --- a/modules/logrotate/cmd/logrotate-tools/logrotate_test.go +++ b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go @@ -74,7 +74,7 @@ func TestADirectiveIsNotALog(t *testing.T) { } func TestGlobalsAreTheBaseWithoutIncludesOrBlocks(t *testing.T) { - g := manifest(t).resource(t, "config")["content"].(string) + g := templateText(t, manifest(t).resource(t, "config")) got := Globals(g) if strings.Join(got, "|") != "weekly|rotate 4|create|compress|delaycompress|tabooext + .pacorig .pacnew .pacsave" { t.Fatalf("%v", got) diff --git a/modules/logrotate/cmd/logrotate-tools/manifest_test.go b/modules/logrotate/cmd/logrotate-tools/manifest_test.go index ce887074..b6ca26cd 100644 --- a/modules/logrotate/cmd/logrotate-tools/manifest_test.go +++ b/modules/logrotate/cmd/logrotate-tools/manifest_test.go @@ -18,13 +18,13 @@ func TestItDeclaresThePackageTheBaseAndTheTimer(t *testing.T) { t.Fatalf("%v", p) } c := m.resource(t, "config") - if c["path"] != BaseConf || c["into"] != nil || !strings.HasPrefix(c["content"].(string), "# The mesh's (module logrotate") { + if c["path"] != BaseConf || c["into"] != nil || !strings.HasPrefix(templateText(t, c), "# The mesh's (module logrotate") { t.Fatalf("%v", c) } - if !strings.Contains(c["content"].(string), "\ninclude "+RulesDir+"\n") { + if !strings.Contains(templateText(t, c), "\ninclude "+RulesDir+"\n") { t.Fatal("the base must include the packages' rules, or nothing of theirs rotates") } - if strings.Contains(c["content"].(string), "olddir") { + if strings.Contains(templateText(t, c), "olddir") { t.Fatal("olddir flattens logs of different directories into one, where two of one name collide") } timer := m.resource(t, "timer") @@ -39,7 +39,7 @@ func TestTheBaseParses(t *testing.T) { t.Skip("logrotate is not installed here; the base configuration is not dry-run") } dir := t.TempDir() - content := strings.ReplaceAll(manifest(t).resource(t, "config")["content"].(string), "include "+RulesDir, "include "+filepath.Join(dir, "d")) + content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d")) if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil { t.Fatal(err) } diff --git a/modules/logrotate/cmd/logrotate-tools/templates_test.go b/modules/logrotate/cmd/logrotate-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/logrotate/files/logrotate.conf b/modules/logrotate/files/logrotate.conf new file mode 100644 index 00000000..d6461375 --- /dev/null +++ b/modules/logrotate/files/logrotate.conf @@ -0,0 +1,32 @@ +# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits. +# Written whole at every push; an edit here is overwritten. Each package's own rules are in +# /etc/logrotate.d and stay the packages'. + +# Weekly, four weeks kept, a new empty log created after each rotation. +weekly +rotate 4 +create + +# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open +# loses nothing to the compression. +compress +delaycompress + +# A package's replaced configuration is never read as a rule. +tabooext + .pacorig .pacnew .pacsave + +include /etc/logrotate.d + +/var/log/wtmp { + monthly + create 0664 root utmp + minsize 1M + rotate 1 +} + +/var/log/btmp { + missingok + monthly + create 0600 root utmp + rotate 1 +} diff --git a/modules/logrotate/module.json b/modules/logrotate/module.json index 23d37678..8318451b 100644 --- a/modules/logrotate/module.json +++ b/modules/logrotate/module.json @@ -25,7 +25,7 @@ "type": "file", "path": "/etc/logrotate.conf", "mode": "0644", - "content": "# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits.\n# Written whole at every push; an edit here is overwritten. Each package's own rules are in\n# /etc/logrotate.d and stay the packages'.\n\n# Weekly, four weeks kept, a new empty log created after each rotation.\nweekly\nrotate 4\ncreate\n\n# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open\n# loses nothing to the compression.\ncompress\ndelaycompress\n\n# A package's replaced configuration is never read as a rule.\ntabooext + .pacorig .pacnew .pacsave\n\ninclude /etc/logrotate.d\n\n/var/log/wtmp {\n monthly\n create 0664 root utmp\n minsize 1M\n rotate 1\n}\n\n/var/log/btmp {\n missingok\n monthly\n create 0600 root utmp\n rotate 1\n}\n" + "template": "files/logrotate.conf" }, { "id": "timer", @@ -47,6 +47,11 @@ "loads": [ "logrotate-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mailu/files/admin.env b/modules/mailu/files/admin.env new file mode 100644 index 00000000..f066bb56 --- /dev/null +++ b/modules/mailu/files/admin.env @@ -0,0 +1,2 @@ +INITIAL_ADMIN_PW=${secret:admin} +API_TOKEN=${secret:api-token} diff --git a/modules/mailu/files/config.json b/modules/mailu/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/mailu/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mailu/files/database.env b/modules/mailu/files/database.env new file mode 100644 index 00000000..ba70d3d6 --- /dev/null +++ b/modules/mailu/files/database.env @@ -0,0 +1,5 @@ +DB_FLAVOR=postgresql +DB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +DB_USER=${bound:postgres-database:as} +DB_NAME=${bound:postgres-database:as} +DB_PW=${secret:postgres-database} diff --git a/modules/mailu/files/mailu.env b/modules/mailu/files/mailu.env new file mode 100644 index 00000000..2c977d30 --- /dev/null +++ b/modules/mailu/files/mailu.env @@ -0,0 +1,78 @@ +ADMIN_ADDRESS=mailu-admin +ANTISPAM_ADDRESS=mailu-antispam +ANTIVIRUS_ADDRESS=mailu-antivirus +IMAP_ADDRESS=mailu-imap +SMTP_ADDRESS=mailu-smtp +FRONT_ADDRESS=mailu-front +WEBMAIL_ADDRESS=mailu-webmail +WEBDAV_ADDRESS=mailu-webdav +REDIS_ADDRESS=mailu-redis +PORTS=25,80,443,465,993,995,4190,110,143,587 +DOMAIN=${config:domain} +HOSTNAMES=${bound:route:name-web} +POSTMASTER=admin +SITENAME=${config:sitename} +WEBSITE=${config:website} +TLS_FLAVOR=letsencrypt +SUBNET=192.168.203.0/24 +COMPOSE_PROJECT_NAME=mailu +ANTIVIRUS=clamav +WEBMAIL=roundcube +WEBDAV=radicale +FETCHMAIL_ENABLED=True +FETCHMAIL_DELAY=600 +ADMIN=true +WEB_ADMIN=/admin +WEB_WEBMAIL=/webmail +WEBROOT_REDIRECT=/webmail +API=true +WEB_API=/api +AUTH_RATELIMIT_IP=6000/hour +AUTH_RATELIMIT_USER=1000/day +CREDENTIAL_ROUNDS=12 +PASSWORD_SCHEME=PBKDF2 +DISABLE_STATISTICS=True +MESSAGE_SIZE_LIMIT=50000000 +MESSAGE_RATELIMIT=200/day +RECIPIENT_DELIMITER=+ +POSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128 +RELAYNETS= +RELAYHOST= +REJECT_UNLISTED_RECIPIENT= +DB_FLAVOR=postgresql +INITIAL_ADMIN_ACCOUNT=admin +INITIAL_ADMIN_DOMAIN=${config:domain} +INITIAL_ADMIN_MODE=ifmissing +SMTP_PORT=25 +SMTPS_PORT=465 +SUBMISSION_PORT=587 +POP3_PORT=110 +POP3S_PORT=995 +IMAP_PORT=143 +IMAPS_PORT=993 +HTTP_PORT=7080 +HTTPS_PORT=7443 +AUTOMX_PORT=4243 +AMX_SMTP_ADDRESS=${bound:route:name-web} +AMX_SMTP_PORT=587 +AMX_IMAP_ADDRESS=${bound:route:name-web} +AMX_IMAP_PORT=143 +AMX_MAIL_DOMAINS=${config:domain} +DMARC_RUA=admin +DMARC_RUF=admin +LETSENCRYPT_SHORTCHAIN=True +TZ=Etc/UTC +LOG_LEVEL=INFO +WELCOME=false +REAL_IP_HEADER=X-Real-IP +REAL_IP_FROM=${config:proxy-address} +COMPRESSION= +COMPRESS_LEVEL= +COMPRESSION_LEVEL= +BIND_ADDRESS4=127.0.0.1 +BIND_ADDRESS6=::1 +MAILU_VERSION=1.9 +DOCKER_ORG=mailu +DOCKER_PREFIX= +WELCOME_SUBJECT=Welcome to your new email account +WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly! diff --git a/modules/mailu/files/secret.env b/modules/mailu/files/secret.env new file mode 100644 index 00000000..c6f6b66b --- /dev/null +++ b/modules/mailu/files/secret.env @@ -0,0 +1 @@ +SECRET_KEY=${secret:secret-key} diff --git a/modules/mailu/health.ts b/modules/mailu/health.ts new file mode 100644 index 00000000..ff6126ff --- /dev/null +++ b/modules/mailu/health.ts @@ -0,0 +1,216 @@ +// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind +// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name: +// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts' +// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it: +// +// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master +// -t`, which this one replaces); +// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the +// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains +// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead +// recipient does not page the operator for days; +// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that +// SERVFAILs refuses inbound mail before anything reaches the queue. +// +// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside +// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason — +// never a bare false. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +/** How long a message may wait in the deferred queue before mail is said not to flow. */ +export const DEFERRED_BOUND_SECONDS = 30 * 60; + +/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */ +export const PUBLIC_NAME = "gmail-smtp-in.l.google.com"; + +// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one +// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback. +const RESOLVE = [ + "import socket, sys", + "try:", + ` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`, + "except Exception as e:", + " sys.exit(type(e).__name__ + ': ' + str(e))", +].join("\n"); + +/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */ +export const SPREAD_DOMAINS = 3; + +/** + * What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not + * found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup + * failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused"). + */ +export const LOCAL_LOOKUP_FAILURE = + /Name service error|Temporary lookup failure|Host or domain name not found.*try again/is; + +/** The domain of an address, lower-cased; "(no recipient)" when there is none. */ +function domainOf(address?: string): string { + const at = address?.lastIndexOf("@") ?? -1; + return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)"; +} + +/** Runs a command in a container: its stdout, or a rejection carrying stderr. */ +export type Exec = (container: string, command: string[]) => Promise; + +/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */ +export interface HealthAnswer { + state: "healthy" | "unhealthy"; + healthy: boolean; + why: string; + queue?: { + messages: number; + deferred: number; + overdue: number; + oldestDeferredSeconds?: number; + overdueDomains?: string[]; + nameServiceFailures?: number; + }; +} + +/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */ +interface Queued { + queue_name?: string; + queue_id?: string; + arrival_time?: number; + recipients?: Array<{ address?: string; delay_reason?: string }>; +} + +/** docker exec, bounded: a hung container is an answer too. */ +export function dockerExec(timeoutMs = 10_000): Exec { + return async (container, command) => + (await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout; +} + +/** What a failed exec said, on one line: its stderr when it has one, else its message. */ +function said(e: unknown): string { + const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string }; + if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time"; + const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim(); + return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing"; +} + +/** An age in words: "2 days 3 hours", "45 minutes". */ +export function age(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); + const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`; + if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day"); + if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour"); + return unit(m, "minute"); +} + +function parseQueue(output: string): Queued[] { + const queued: Queued[] = []; + for (const line of output.split("\n")) { + const text = line.trim(); + if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like + queued.push(JSON.parse(text) as Queued); + } + return queued; +} + +/** + * Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one + * fails, so the answer says everything that is wrong at once. + */ +export async function mailFlows( + exec: Exec, + container: string, + now: number = Date.now() / 1000, + boundSeconds: number = DEFERRED_BOUND_SECONDS, +): Promise { + const faults: string[] = []; + const [status, queue, resolve] = await Promise.allSettled([ + exec(container, ["postfix", "status"]), + exec(container, ["postqueue", "-j"]), + exec(container, ["python3", "-c", RESOLVE]), + ]); + + if (status.status === "rejected") { + faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`); + } + + let summary: HealthAnswer["queue"]; + const notes: string[] = []; + if (queue.status === "rejected") { + faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`); + } else { + let queued: Queued[] = []; + try { + queued = parseQueue(queue.value); + } catch (e) { + faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`); + } + const deferred = queued.filter((q) => q.queue_name === "deferred"); + const overdue = deferred + .filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds) + .sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0)); + const oldest = deferred.reduce( + (o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o), + undefined, + ); + // Each overdue recipient, oldest message first: what our side is judged by, not one far host. + const late = overdue.flatMap((q) => + (q.recipients?.length ? q.recipients : [{}]).map((r) => ({ + to: r.address, + domain: domainOf(r.address), + reason: r.delay_reason?.trim() || "postfix gave no reason", + waited: now - (q.arrival_time ?? now), + })), + ); + const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason)); + const domains = [...new Set(late.map((r) => r.domain))]; + summary = { + messages: queued.length, + deferred: deferred.length, + overdue: overdue.length, + ...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}), + ...(domains.length > 0 ? { overdueDomains: domains } : {}), + ...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}), + }; + const one = (r: (typeof late)[number]) => + `the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`; + const bound = age(boundSeconds); + if (lookups.length > 0) { + faults.push( + `${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` + + `than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`, + ); + } else if (domains.length >= SPREAD_DOMAINS) { + faults.push( + `${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` + + `${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`, + ); + } else if (late.length > 0) { + notes.push( + `${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` + + `${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`, + ); + } + } + + if (resolve.status === "rejected") { + faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`); + } + + if (faults.length > 0) { + const why = [`Mail does not flow.`, ...faults, ...notes].join(" "); + return { state: "unhealthy", healthy: false, why, queue: summary }; + } + const waiting = !summary || summary.deferred === 0 + ? " The deferred queue is empty." + : notes.length > 0 + ? ` ${notes.join(" ")}` + : ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`; + return { + state: "healthy", + healthy: true, + why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`, + queue: summary, + }; +} diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 3a4a3568..52750c98 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -5,6 +5,10 @@ "policy": "record", "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" }, + "unhealthy": { + "severity": "urgent", + "why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)" + }, "capabilities": [ "container-runtime" ], @@ -56,6 +60,19 @@ "api-token": "${dir:state}/api-token.secret" } }, + "tools": [ + "mailu_list_users", + "mailu_create_user", + "mailu_change_password", + "mailu_delete_user", + "mailu_list_aliases", + "mailu_create_alias", + "mailu_delete_alias", + "mailu_list_domains", + "mailu_read_mail", + "mailu_search_mail", + "mailu_health" + ], "emits": [ "user.created", "user.deleted", @@ -231,6 +248,44 @@ } } }, + "config": { + "domain": { + "type": "string", + "description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "proxy-address": { + "type": "string", + "description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.", + "trusted": true, + "required": true + }, + "sitename": { + "type": "string", + "description": "The name Mailu's web pages show for the mail service; the operator's, with no default.", + "trusted": true, + "required": true + }, + "website": { + "type": "string", + "description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.", + "trusted": true, + "required": true + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.", + "trusted": true, + "default": "" + }, + "container": { + "type": "string", + "description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -259,28 +314,28 @@ "type": "file", "path": "${dir:state}/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=${setting:domain}\nHOSTNAMES=${bound:route:name-web}\nPOSTMASTER=admin\nSITENAME=${setting:sitename}\nWEBSITE=${setting:website}\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=${setting:domain}\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=${bound:route:name-web}\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=${bound:route:name-web}\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=${setting:domain}\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=${setting:proxy-address}\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "template": "files/mailu.env" }, { "id": "secret-env", "type": "file", "path": "${dir:state}/secret.env", "mode": "0600", - "content": "SECRET_KEY=${secret:secret-key}\n" + "template": "files/secret.env" }, { "id": "database-env", "type": "file", "path": "${dir:state}/database.env", "mode": "0600", - "content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n" + "template": "files/database.env" }, { "id": "admin-env", "type": "file", "path": "${dir:state}/admin.env", "mode": "0600", - "content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n" + "template": "files/admin.env" }, { "id": "data-certs", @@ -371,7 +426,7 @@ "id": "resolver", "type": "container", "name": "mailu-resolver", - "image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a", + "image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e", "health": { "kind": "runtime" }, @@ -443,7 +498,12 @@ "name": "mailu-smtp", "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", "health": { - "kind": "runtime" + "kind": "tool", + "tool": "mailu_health", + "interval": "60s", + "timeout": "30s", + "looks": 3, + "grace": "120s" }, "network": "mailu", "env-file": [ @@ -560,7 +620,12 @@ "name": "mailu-front", "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", "health": { - "kind": "runtime" + "kind": "exec", + "command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem", + "interval": "60s", + "timeout": "15s", + "looks": 3, + "grace": "120s" }, "network": "mailu", "env-file": [ @@ -591,7 +656,8 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"], "merge": "json" }, { @@ -637,6 +703,7 @@ "MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1", "MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", + "MESH_MAILU_SMTP_CONTAINER": "mailu-smtp", "MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_RECEIVES": "${dir:grants}/mesh.json" } @@ -645,6 +712,11 @@ "name": "automx", "kind": "image", "from": "automx/Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -661,7 +733,7 @@ "serves": { "smtp": { "port": 587, - "domain": "${setting:domain}" + "domain": "${config:domain}" } }, "receives": { @@ -673,7 +745,7 @@ "jails": [ { "name": "mailu-front", - "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)", + "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: , server: \\S+:25,", "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" } ] diff --git a/modules/mailu/test/mail-flows.test.ts b/modules/mailu/test/mail-flows.test.ts new file mode 100644 index 00000000..ddea9791 --- /dev/null +++ b/modules/mailu/test/mail-flows.test.ts @@ -0,0 +1,140 @@ +// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in +// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not +// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled +// module, the way the runtime loads it. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js"; + +const NOW = 1_800_000_000; + +type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error }; + +/** A container that answers each command as told; an Error is a failed exec, with its stderr. */ +function container(a: Answers) { + const asked: string[][] = []; + const exec = async (_name: string, command: string[]) => { + asked.push(command); + const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? ""; + if (answer instanceof Error) throw answer; + return answer; + }; + return { exec, asked }; +} + +function failed(stderr: string): Error { + return Object.assign(new Error("Command failed: docker exec"), { stderr }); +} + +function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string { + return JSON.stringify({ + queue_name: "deferred", + queue_id: id, + arrival_time: NOW - ageSeconds, + message_size: 1234, + sender: "someone@example.org", + recipients: [{ address: to, delay_reason: reason }], + }); +} + +test("an empty queue is healthy, and says so", async () => { + const c = container({ queue: "" }); + const a = await mailFlows(c.exec, "mailu-smtp", NOW); + assert.equal(a.state, "healthy"); + assert.equal(a.healthy, true); + assert.match(a.why, /deferred queue is empty/); + assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 }); + assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked"); +}); + +test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => { + const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again"; + const queue = [ + deferred("AAA111", 3 * 86400 + 2 * 3600, reason), + deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"), + JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }), + ].join("\n"); + const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "unhealthy"); + assert.equal(a.healthy, false); + assert.match(a.why, /^Mail does not flow\./); + assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/); + assert.match(a.why, /3 days 2 hours old to friend@example\.com/); + assert.ok(a.why.includes(reason), "the reason is said"); + assert.deepEqual(a.queue, { + messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600, + overdueDomains: ["example.com"], nameServiceFailures: 1, + }); +}); + +test("one dead far domain overdue is healthy, and said", async () => { + const queue = [ + deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"), + deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"), + ].join("\n"); + const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "healthy"); + assert.equal(a.healthy, true); + assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/); + assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/); + assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]); +}); + +test("overdue mail to three domains is unhealthy, whatever the reason", async () => { + const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out"; + const queue = [ + deferred("AAA111", 7200, timedOut, "a@one.example"), + deferred("BBB222", 3600, timedOut, "b@two.example"), + deferred("CCC333", 3600, timedOut, "c@three.example"), + ].join("\n"); + const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "unhealthy"); + assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/); + assert.match(a.why, /the oldest, 2 hours old to a@one\.example/); +}); + +test("a deferred message younger than the bound is healthy, and is said", async () => { + const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out"); + const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "healthy"); + assert.equal(a.healthy, true); + assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/); +}); + +test("a container that cannot be reached is unhealthy, and says what docker said", async () => { + const gone = failed("Error response from daemon: No such container: mailu-smtp\n"); + const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "unhealthy"); + assert.equal(a.healthy, false); + assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./); + assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/); + assert.equal(a.queue, undefined); +}); + +test("a name that does not resolve is unhealthy, though the queue is empty", async () => { + const a = await mailFlows( + container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec, + "mailu-smtp", + NOW, + ); + assert.equal(a.state, "unhealthy"); + assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./); +}); + +test("a container that does not answer in time is said so", async () => { + const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" }); + const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW); + assert.equal(a.state, "unhealthy"); + assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./); +}); + +test("the health tool is served without the admin API, on the smtp container it is told", async () => { + const { collectTools } = await import("@novox/mesh-sdk/tools"); + await import("../dist/tools/index.js"); + const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu"); + const names = (mailu?.tools ?? []).map((t) => t.name); + assert.deepEqual(names, ["mailu_health"]); + assert.match(mailu!.tools[0].description, /inside mailu-smtp/); +}); diff --git a/modules/mailu/tools/index.ts b/modules/mailu/tools/index.ts index c1ab925b..6b8442e9 100644 --- a/modules/mailu/tools/index.ts +++ b/modules/mailu/tools/index.ts @@ -4,6 +4,27 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { MailuClient } from "../client.js"; +import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js"; + +/** + * Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's + * check. It needs no admin API, only the postfix container, so it is served even when the API is not + * configured — a check that vanished with the API would say unhealthy for the wrong reason. + */ +export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition { + const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp"; + return { + name: "mailu_health", + description: + `Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` + + `deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` + + `deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` + + `unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` + + "oldest message's age and its reason (novox/hq ADR 0240, issue 690).", + input: {}, + run: async () => mailFlows(dockerExec(), container), + }; +} export function getMailuTools(mailu: MailuClient): ToolDefinition[] { return [ @@ -143,12 +164,14 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] { ]; } -// The tools exist only when the admin API is configured; without it, mailu contributes none rather -// than failing the whole runtime. +// The admin tools exist only when the admin API is configured; without it, mailu contributes only its +// health tool rather than failing the whole runtime. registerModuleTools("mailu", (env) => { + let tools: ToolDefinition[] = []; try { - return getMailuTools(MailuClient.fromEnv(env)); + tools = getMailuTools(MailuClient.fromEnv(env)); } catch { - return []; + tools = []; } + return [...tools, getHealthTool(env)]; }); diff --git a/modules/mailu/tsconfig.json b/modules/mailu/tsconfig.json index 36778592..a55a92ee 100644 --- a/modules/mailu/tsconfig.json +++ b/modules/mailu/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "tools/index.ts"] + "include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"] } diff --git a/modules/matrix/files/conduit.toml b/modules/matrix/files/conduit.toml new file mode 100644 index 00000000..9b724010 --- /dev/null +++ b/modules/matrix/files/conduit.toml @@ -0,0 +1,15 @@ +# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes +# from the environment. server_name is the homeserver's permanent identity: every user id, room id +# and signature in the database carries it, so it is the name this module is served under +# (${bound:route:name-homeserver}) and never changes once a database exists. +[global] +server_name = "${bound:route:name-homeserver}" +database_backend = "rocksdb" +database_path = "/var/lib/matrix-conduit/" +address = "0.0.0.0" +port = 6167 +max_request_size = 20000000 +allow_registration = false +allow_federation = true +allow_check_for_updates = true +trusted_servers = ["matrix.org"] diff --git a/modules/matrix/files/element.json b/modules/matrix/files/element.json new file mode 100644 index 00000000..305a89f7 --- /dev/null +++ b/modules/matrix/files/element.json @@ -0,0 +1,53 @@ +{ + "default_server_name": "${bound:route:name-homeserver}", + "default_server_config": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + }, + "brand": "Element", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ], + "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "uisi_autorageshake_app": "element-auto-uisi", + "show_labs_settings": true, + "room_directory": { + "servers": [ + "${bound:route:name-homeserver}", + "matrix.org", + "gitter.im", + "libera.chat" + ] + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + }, + "terms_and_conditions_links": [ + { + "url": "https://element.io/privacy", + "text": "Privacy Policy" + }, + { + "url": "https://element.io/cookie-policy", + "text": "Cookie Policy" + } + ], + "features": { + "feature_video_rooms": true, + "feature_rust_crypto": true + }, + "element_call": { + "url": "https://call.element.dev" + } +} diff --git a/modules/matrix/module.json b/modules/matrix/module.json index eded5f65..2c044576 100644 --- a/modules/matrix/module.json +++ b/modules/matrix/module.json @@ -48,6 +48,113 @@ } ] }, + "config": { + "default_server_name": { + "type": "string", + "description": "Element's default_server_name setting of config.json: the homeserver Element signs in to by default, by name; the file's default is the module's own route.", + "trusted": true, + "default": "${bound:route:name-homeserver}" + }, + "default_server_config": { + "type": "object", + "description": "Element's default_server_config setting of config.json: the homeserver and identity server Element uses by default, by URL.", + "trusted": true, + "default": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + } + }, + "brand": { + "type": "string", + "description": "Element's brand setting of config.json: the name Element shows itself as.", + "trusted": true, + "default": "Element" + }, + "integrations_ui_url": { + "type": "string", + "description": "Element's integrations_ui_url setting of config.json: the integration manager's page, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/" + }, + "integrations_rest_url": { + "type": "string", + "description": "Element's integrations_rest_url setting of config.json: the integration manager's API, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/api" + }, + "integrations_widgets_urls": { + "type": "list", + "description": "Element's integrations_widgets_urls setting of config.json: the URLs whose widgets Element trusts as the integration manager's.", + "trusted": true, + "default": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ] + }, + "bug_report_endpoint_url": { + "type": "string", + "description": "Element's bug_report_endpoint_url setting of config.json: where Element sends a bug report, a URL; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": "" + }, + "uisi_autorageshake_app": { + "type": "string", + "description": "Element's uisi_autorageshake_app setting of config.json: the app name Element files an automatic report of an undecryptable message under.", + "trusted": true, + "default": "element-auto-uisi" + }, + "show_labs_settings": { + "type": "boolean", + "description": "Element's show_labs_settings setting of config.json: whether Element shows its Labs settings.", + "trusted": true, + "default": true + }, + "room_directory": { + "type": "object", + "description": "Element's room_directory setting of config.json: the servers whose room directories Element offers; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": {} + }, + "enable_presence_by_hs_url": { + "type": "object", + "description": "Element's enable_presence_by_hs_url setting of config.json: whether Element shows presence, per homeserver URL.", + "trusted": true, + "default": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + } + }, + "terms_and_conditions_links": { + "type": "list", + "description": "Element's terms_and_conditions_links setting of config.json: the links Element shows as its terms, each a URL and its text; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": [] + }, + "features": { + "type": "object", + "description": "Element's features setting of config.json: Element's feature switches, each on or off.", + "trusted": true, + "default": { + "feature_video_rooms": true, + "feature_rust_crypto": true + } + }, + "element_call": { + "type": "object", + "description": "Element's element_call setting of config.json: where Element starts a call with Element Call, a URL.", + "trusted": true, + "default": { + "url": "https://call.element.dev" + } + } + }, "resources": [ { "id": "state", @@ -65,7 +172,7 @@ "type": "file", "path": "${dir:state}/conduit.toml", "mode": "0644", - "content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n", + "template": "files/conduit.toml", "names-on-purpose": { "matrix.org": "the federation's public key server, trusted by default; the world's, not this mesh's" } @@ -76,7 +183,8 @@ "path": "${dir:state}/element.json", "mode": "0644", "merge": "json", - "content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n", + "takes-config": ["default_server_name", "default_server_config", "brand", "integrations_ui_url", "integrations_rest_url", "integrations_widgets_urls", "bug_report_endpoint_url", "uisi_autorageshake_app", "show_labs_settings", "room_directory", "enable_presence_by_hs_url", "terms_and_conditions_links", "features", "element_call"], + "template": "files/element.json", "names-on-purpose": { "matrix.org": "the public room directory and the federation's largest homeserver; the world's", "matrix-client.matrix.org": "the same homeserver's client endpoint; the world's", @@ -134,5 +242,14 @@ "element-conf" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/memory-pressure/cmd/memory-pressure/templates_test.go b/modules/memory-pressure/cmd/memory-pressure/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/memory-pressure/files/oomd.conf b/modules/memory-pressure/files/oomd.conf new file mode 100644 index 00000000..39af6def --- /dev/null +++ b/modules/memory-pressure/files/oomd.conf @@ -0,0 +1,8 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is +# 90 % used, or when a watched unit stalls on memory for 20 s above its limit. +[OOM] +SwapUsedLimit=90% +DefaultMemoryPressureLimit=60% +DefaultMemoryPressureDurationSec=20s diff --git a/modules/memory-pressure/files/root-slice.conf b/modules/memory-pressure/files/root-slice.conf new file mode 100644 index 00000000..edae3de8 --- /dev/null +++ b/modules/memory-pressure/files/root-slice.conf @@ -0,0 +1,5 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may act on swap exhaustion across the whole machine. +[Slice] +ManagedOOMSwap=kill diff --git a/modules/memory-pressure/files/sysctl.conf b/modules/memory-pressure/files/sysctl.conf new file mode 100644 index 00000000..678251cb --- /dev/null +++ b/modules/memory-pressure/files/sysctl.conf @@ -0,0 +1,9 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in +# RAM has none — the speculation only costs decompression and memory. +vm.page-cluster = 0 +# +# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the +# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing +# being avoided. Raise it only together with zram-size. diff --git a/modules/memory-pressure/files/user-manager.conf b/modules/memory-pressure/files/user-manager.conf new file mode 100644 index 00000000..8ef300c6 --- /dev/null +++ b/modules/memory-pressure/files/user-manager.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays +# above 80 % — instead of the kernel freezing the machine. +[Service] +ManagedOOMMemoryPressure=kill +ManagedOOMMemoryPressureLimit=80% diff --git a/modules/memory-pressure/files/zram-generator.conf b/modules/memory-pressure/files/zram-generator.conf new file mode 100644 index 00000000..cccfb05a --- /dev/null +++ b/modules/memory-pressure/files/zram-generator.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday +# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd. +# The swap on disk, its size and whether one exists at all are the machine's, not this module's. +# +# Read by the generator at boot: a change applies at the next boot. +[zram0] +zram-size = min(ram / 2, 16384) +compression-algorithm = zstd +swap-priority = 100 diff --git a/modules/memory-pressure/module.json b/modules/memory-pressure/module.json index 95eeb71b..76ae53be 100644 --- a/modules/memory-pressure/module.json +++ b/modules/memory-pressure/module.json @@ -28,7 +28,7 @@ "type": "file", "path": "/etc/systemd/zram-generator.conf", "mode": "0644", - "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday\n# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd.\n# The swap on disk, its size and whether one exists at all are the machine's, not this module's.\n#\n# Read by the generator at boot: a change applies at the next boot.\n[zram0]\nzram-size = min(ram / 2, 16384)\ncompression-algorithm = zstd\nswap-priority = 100\n" + "template": "files/zram-generator.conf" }, { "id": "sysctl-drop-ins", @@ -41,7 +41,7 @@ "type": "file", "path": "/etc/sysctl.d/90-memory-pressure.conf", "mode": "0644", - "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in\n# RAM has none — the speculation only costs decompression and memory.\nvm.page-cluster = 0\n#\n# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the\n# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing\n# being avoided. Raise it only together with zram-size.\n" + "template": "files/sysctl.conf" }, { "id": "sysctl", @@ -62,7 +62,7 @@ "type": "file", "path": "/etc/systemd/oomd.conf.d/memory-pressure.conf", "mode": "0644", - "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is\n# 90 % used, or when a watched unit stalls on memory for 20 s above its limit.\n[OOM]\nSwapUsedLimit=90%\nDefaultMemoryPressureLimit=60%\nDefaultMemoryPressureDurationSec=20s\n" + "template": "files/oomd.conf" }, { "id": "root-slice-drop-ins", @@ -75,7 +75,7 @@ "type": "file", "path": "/etc/systemd/system/-.slice.d/10-oomd.conf", "mode": "0644", - "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may act on swap exhaustion across the whole machine.\n[Slice]\nManagedOOMSwap=kill\n" + "template": "files/root-slice.conf" }, { "id": "user-manager-drop-ins", @@ -88,7 +88,7 @@ "type": "file", "path": "/etc/systemd/system/user@.service.d/10-oomd.conf", "mode": "0644", - "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays\n# above 80 % — instead of the kernel freezing the machine.\n[Service]\nManagedOOMMemoryPressure=kill\nManagedOOMMemoryPressureLimit=80%\n" + "template": "files/user-manager.conf" }, { "id": "oomd", @@ -115,6 +115,11 @@ "loads": [ "memory-pressure" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mesh-catalog/files/database.url b/modules/mesh-catalog/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/modules/mesh-catalog/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts new file mode 100644 index 00000000..4dab234f --- /dev/null +++ b/modules/mesh-catalog/heard.ts @@ -0,0 +1,96 @@ +// What the catalogue makes of a build outcome it hears (novox/hq issue 712). +// +// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus. + +import type { Made } from "./store.js"; + +/** What the build seat says when it has built something, and what the control plane replays. */ +export interface Built { + module?: string; + commit?: string; + repository?: string; + path?: string; + ref?: string; + manifest?: unknown; + /** What this build published, each pinned as anything else would name it. */ + made?: Made[]; + /** + * Every artifact this was built on top of, so the edge is derived rather than declared. + * + * References, not module names: the builder sees a pinned image and cannot see which module + * produced it. Turning that into an edge between module-versions is this module's job. + */ + against?: string[]; + /** + * This is history, not news — the mesh re-announcing a build this catalogue was not there for. + * + * Registered exactly as any other, and announced as nothing. A module that moved months ago is + * not something anything should act on now: emitting `upgraded` would have the control plane + * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already + * current. + */ + replay?: boolean; + /** Why the build failed; a failed build made no module-version. */ + failed?: string; + /** A dry run is looked at, never taken in (novox/hq issue 240). */ + "dry-run"?: boolean; + /** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */ + "check-outcome"?: unknown; + check?: unknown; + /** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */ + trunk?: string; + /** Whether the commit is on `trunk`. */ + "on-trunk"?: boolean; + /** The branches the commit is on. */ + branches?: string[]; +} + +/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */ +export const BUILD_EVENTS = [ + // **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the + // retired seat below, so every build after the handover reached the graph only through the replay this + // module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start. + "node-build-agent.built", + // The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row. + "mesh-build-machine.built", + // What the mesh already held, when this module asks what it missed (novox/hq ADR 0134). + "mesh-controller.built-before", +] as const; + +/** + * Whether a build outcome is a module-version to place in the graph, and why not when it is not. + * + * It mirrors two of the rules the controller keeps before it registers a build (mesh-controller + * cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are + * never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through + * the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the + * rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too. + * + * Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not + * the default one (the controller's `follows`; such a module's builds off the default branch are passed over + * here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository + * it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a + * build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh + * sends, which is chosen from the controller's own module table and never from this graph; that the graph + * can still hold a build the controller refused under those rules is a follow-up (hq issue 712). + */ +export function placeable(body: Built): + | { placed: "yes"; module: string; commit: string } + | { placed: "no"; why: string; fault: boolean } { + if (body["check-outcome"] != null || body.check != null) { + return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false }; + } + if (body["dry-run"]) { + return { placed: "no", why: "a dry run is looked at, never taken in", fault: false }; + } + if (body.failed) { + return { placed: "no", why: "a failed build made no module-version", fault: false }; + } + if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) { + return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false }; + } + if (!body.module || !body.commit) { + return { placed: "no", why: "it named no module or no commit", fault: true }; + } + return { placed: "yes", module: body.module, commit: body.commit }; +} diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 74f218e8..13a98d2d 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -10,7 +10,8 @@ // diamond need no special handling, and nothing holds a plan. import { on, emit } from "@novox/mesh-sdk/events"; -import { Graph, type Made } from "./store.js"; +import { Graph } from "./store.js"; +import { BUILD_EVENTS, placeable, type Built } from "./heard.js"; const graph = Graph.fromEnv(); @@ -21,54 +22,29 @@ const graph = Graph.fromEnv(); // would block the very apply that brings the overlay up — stopped being true when a step's failure // became this module's business and not the machine's (ADR 0136). -/** What the builder says when it has built something. */ -interface Built { - module?: string; - commit?: string; - repository?: string; - path?: string; - ref?: string; - manifest?: unknown; - /** What this build published, each pinned as anything else would name it. */ - made?: Made[]; - /** - * Every artifact this was built on top of, so the edge is derived rather than declared. - * - * References, not module names: the builder sees a pinned image and cannot see which module - * produced it. Turning that into an edge between module-versions is this module's job. - */ - against?: string[]; - /** - * This is history, not news — the mesh re-announcing a build this catalogue was not there for. - * - * Registered exactly as any other, and announced as nothing. A module that moved months ago is - * not something anything should act on now: emitting `upgraded` would have the control plane - * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already - * current. - */ - replay?: boolean; -} - /** * What a build means for the graph, wherever it came from. * * Two emitters say the same thing and neither is a mistake: the build machine says it as it happens, * and the control plane says what it already held when this module asks what it missed * (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved - * months ago — see `replay` above. + * months ago — see `replay` in heard.ts. */ const placeTheBuild = async (event: { body: unknown }): Promise => { const body = (event.body ?? {}) as Built; - if (!body.module || !body.commit) { - // Said rather than dropped: a build that announced itself without saying what it built is a - // fault in the builder, and a silent discard here would make it look like a missing event. - console.error("mesh-catalog: a build event named no module or no commit; ignored", body); + const placing = placeable(body); + if (placing.placed === "no") { + // A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One + // that names no module or commit is a fault in the builder, said rather than dropped, so it does + // not look like a missing event. + if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body); return; } + const { module, commit } = placing; const { upgraded, previous } = await graph.register({ - module: body.module, - commit: body.commit, + module, + commit, repository: body.repository ?? "", path: body.path ?? "", ref: body.ref ?? "", @@ -80,7 +56,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (body.replay) return; await emit("registered", { - module: body.module, commit: body.commit, upgraded, + module, commit, upgraded, }); // **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward @@ -88,7 +64,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (!upgraded) return; await emit("upgraded", { - module: body.module, commit: body.commit, previous, + module, commit, previous, }); // What can be built now — stale, and waiting on nothing that is itself stale. @@ -101,9 +77,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { } }; -// As it happens, and what the mesh already held when this module asked what it missed. -await on("mesh-build-machine.built", placeTheBuild); -await on("mesh-controller.built-before", placeTheBuild); +// As it happens — from the build seat that builds now, and the retired one while it still answers — and +// what the mesh already held when this module asked what it missed (novox/hq issue 712). +for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild); // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index b5f69958..5a263800 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -26,6 +26,7 @@ "postgres-database": "${dir:state}/database.secret" }, "consumes": [ + "node-build-agent.built", "mesh-build-machine.built", "mesh-controller.built-before" ], @@ -47,7 +48,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "prepare", @@ -85,6 +86,11 @@ "env": { "DATABASE_URL_FILE": "${dir:state}/database.url" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts new file mode 100644 index 00000000..e00daa1b --- /dev/null +++ b/modules/mesh-catalog/test/heard.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { BUILD_EVENTS, placeable } from "../heard.js"; + +// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712. +const walkBuild = { + id: "build-1791997333800041716", + repository: "http://novox.internal:20000/novox/mesh-catalog.git", + path: "modules/mailu", + ref: "main", + on: "shanks", + trunk: "main", + "on-trunk": true, + branches: ["main"], + module: "mailu", + commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87", + manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } }, + made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }], +}; + +test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => { + assert.ok(BUILD_EVENTS.includes("node-build-agent.built")); + assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before")); + // Subscribing is not enough: the mesh grants and routes only what the manifest consumes. + const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); + for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`); +}); + +test("a walk's build is placed, under its module and commit", () => { + assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit }); + // A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2). + assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes"); +}); + +test("a merge check, a try, a dry run and a failure are passed over without a fault", () => { + for (const [body, why] of [ + [{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"], + [{ ...walkBuild, check: { try: true } }, "merge check or a try"], + [{ ...walkBuild, "dry-run": true }, "dry run"], + [{ ...walkBuild, failed: "exit status 1" }, "failed"], + ] as const) { + const placing = placeable(body); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { + assert.match(placing.why, new RegExp(why)); + assert.equal(placing.fault, false); + } + } +}); + +test("an outcome that names no module or commit is said as the builder's fault", () => { + const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit }); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") assert.equal(placing.fault, true); +}); + +test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => { + const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] }; + const placing = placeable(branch); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { + assert.match(placing.why, /not on its repository's trunk main/); + assert.equal(placing.fault, false); + } + // On the trunk by its branches although the seat did not set on-trunk. + assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes"); + // A seat older than the rule says no trunk: placed, as the controller lets it through. + const { trunk, "on-trunk": _on, branches, ...older } = walkBuild; + assert.equal(placeable(older).placed, "yes"); +}); diff --git a/modules/mesh-catalog/tsconfig.json b/modules/mesh-catalog/tsconfig.json index 4e95d4fa..c1eaae10 100644 --- a/modules/mesh-catalog/tsconfig.json +++ b/modules/mesh-catalog/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "pg.d.ts", "store.ts", + "heard.ts", "index.ts", "tools/index.ts", "prepare/index.ts" diff --git a/modules/mesh-cli/cmd/mesh-cli/manifest_test.go b/modules/mesh-cli/cmd/mesh-cli/manifest_test.go index bf143a9b..dec228c1 100644 --- a/modules/mesh-cli/cmd/mesh-cli/manifest_test.go +++ b/modules/mesh-cli/cmd/mesh-cli/manifest_test.go @@ -31,10 +31,22 @@ func TestTheArchivePlacesExactlyOneFile(t *testing.T) { if r["type"] != "archive" || r["path"] != "/usr/local/bin" { t.Fatalf("its resource is %v", r) } - if len(m.Build.Artifacts) != 1 { - t.Fatalf("it builds %d artifacts", len(m.Build.Artifacts)) + // The build has a second artifact, the files archive the controller reads the templates back from; it + // places no file. The artifact the resource unpacks is the one that does. + var a map[string]any + for _, candidate := range m.Build.Artifacts { + if candidate["name"] == r["artifact"] { + if a != nil { + t.Fatalf("two artifacts are named %v", r["artifact"]) + } + a = candidate + } else if candidate["kind"] != "archive" || candidate["from"] != "files" { + t.Fatalf("it builds %v beside the artifact that places the file", candidate) + } + } + if a == nil { + t.Fatalf("no artifact is named %v", r["artifact"]) } - a := m.Build.Artifacts[0] if a["name"] != r["artifact"] || a["kind"] != "bundle" || a["language"] != "go" || a["binary"] != "mesh-cli" || a["from"] != "cmd/mesh-cli" { t.Fatalf("its artifact is %v, unpacked as %v", a, r["artifact"]) diff --git a/modules/mesh-cli/files/zsh-normal b/modules/mesh-cli/files/zsh-normal new file mode 100644 index 00000000..117f2a5a --- /dev/null +++ b/modules/mesh-cli/files/zsh-normal @@ -0,0 +1,3 @@ +# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only. +# A script, sudo and a document meant to work everywhere say mesh-cli. +alias nox=mesh-cli diff --git a/modules/mesh-cli/module.json b/modules/mesh-cli/module.json index 3939eb35..a72f91ac 100644 --- a/modules/mesh-cli/module.json +++ b/modules/mesh-cli/module.json @@ -1,6 +1,7 @@ { "module": "mesh-cli", "version": "1", + "moved": true, "resources": [ { "id": "program", @@ -9,11 +10,10 @@ "path": "/usr/local/bin" } ], - "shell": [ + "items": [ { - "for": "zsh", - "slot": "normal", - "code": "# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.\n# A script, sudo and a document meant to work everywhere say mesh-cli.\nalias nox=mesh-cli\n" + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" } ], "build": { @@ -25,6 +25,11 @@ "system": "arch", "from": "cmd/mesh-cli", "binary": "mesh-cli" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mesh-config/README.md b/modules/mesh-config/README.md new file mode 100644 index 00000000..7a9ab019 --- /dev/null +++ b/modules/mesh-config/README.md @@ -0,0 +1,52 @@ +# mesh-config + +Holds the `mesh-config` seat: the owner of every config value (novox/hq ADR 0348 §9, design 57 K3). Go, one +binary (`cmd/mesh-config`), served by a runtime of its own on its own bus account (`runs-as`), so that its +account alone writes its buckets. + +## What it keeps + +- `mesh-config_values`: each value set, keyed by its level: `mesh.` (mesh config), `node..` + (node config) and `module...` (a module's own value on one node), each with who set it, + through which account, why and when. +- `mesh-config_resolved`: each assigned module's value for each key it declares on its node, keyed + `..`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value + nothing sets. +- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror + `mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a + value at the mesh or a node is withheld, and every listing says why, until that value is cleared. +- `mesh-config_proposals`: each value proposed for the operator's Approve, whole, with the value stored at its level + when it was proposed (what the operator is shown the change against), who proposed it, why, and what came of it. +- the log `history`: every set, clear and move of resolved values, with the proven caller and why. + +## How a value resolves + +From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`, +`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back +to; node config for that key on the node; the module's own value on that node, the most specific winning. A value +naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at +start, whenever a mirror moves, and every five minutes, writing only the values that moved. + +## The verb + +`mesh-config.config` with `action` `list` (the default), `set`, `clear` or `history`, narrowed by `module`, `node` +and `key`. A set or a clear names one level (no module and no node for mesh config, a node for node config, both +for a module's own value), needs `why`, refuses a key the module or the predefined list does not declare (naming +the declared keys), validates the value, writes it and the resolved values it moves, keeps it in the history and +the controller's hand-act log, raises `config-changed`, and answers with each module on each node whose value +moved. It opens no walk. A trusted value is taken only from the controller, which carries it from its terminal and +names who gave it in `by`, or on the operator's Approve of a proposal. + +## Proposing a value (ADR 0348 §8, design 57 K4) + +`action` `propose` (with `value`, or `clear`) judges the value as a set would, keeps it in `mesh-config_proposals`, +and asks the operator under mesh-config's own name on the `operator-channel` seat (its own grant, from `uses`, and +its own cap of three open asks; the same change twice is refused, and a newer proposal for the same level and key +replaces the older). The ask says the level, the key and the value, exact where who answers is proven, and that, +approved, it takes effect at once on every node it reaches; Approve and Decline are both at the level approve and +each binds the proposal's level, key, value and the value it was shown against. On the answer mesh-config reads +the router's own record of its ask and holds the warrant to the ask composed again from the proposal; it refuses, +and sets nothing, when the proposal or the value stored at the level changed since. An Approve that holds sets the +value as a set does, keeps who approved it beside the value (`approved`), and asks the controller to record the +warrant in its hand-act log. A Decline, an expiry or the router's refusal sets nothing. `action` `proposals` lists +them, newest first; with `proposal`, one read whole, with whether the router's ask is the one composed from it. diff --git a/modules/mesh-config/cmd/mesh-config/config.go b/modules/mesh-config/cmd/mesh-config/config.go new file mode 100644 index 00000000..cae5d361 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/config.go @@ -0,0 +1,438 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// The verb `config` (novox/hq ADR 0348 §1, §8, §9, §10, design 57 K3): set or clear a value at one level with why, +// list the values, and read their history, narrowed by module and node. A set refuses a key the module or the list +// of predefined keys does not declare, naming the declared keys; validates the value; writes it and the resolved +// values it moves; records the change in the history and the hand-act log; raises `config-changed`; and answers +// with each module on each node whose value moved. A config change opens no walk and waits at no first-node gate. +// +// **Trusted values at the terminal or on the operator's Approve.** A trusted value (ADR 0348 §8) is set only by a +// call the controller carries from its terminal, or on the operator's warrant for a proposal (proposals.go, design +// 57 K4); a set through the verb is refused and names the proposal as the way. + +// ControllerCaller is the controller's bus user: the one caller that carries a value given at its terminal. +const ControllerCaller = "controller" + +// Unproven is the caller of a call the runtime named none for (the SDK's word). +const Unproven = "unproven" + +// Entry is one key's current value in a state. +type Entry struct { + Key string + Value json.RawMessage + Revision uint64 +} + +// Store is the bus as this module reaches it: the state it keeps and reads, its history log, its events, and the +// controller's hand-act log. +type Store interface { + Get(state, key string) (*Entry, error) + Put(state, key string, value any) error + Delete(state, key string) error + Keys(state string) ([]string, error) + Append(log, key string, value any) error + Read(log string) ([]LogEntry, error) + Emit(event string, body any) error + HandAct(what, why, cause string) error +} + +// LogEntry is one entry of a log. +type LogEntry struct { + Key string + Value json.RawMessage + At time.Time +} + +// ConfigChanged is the event every change raises: what was set or cleared at which level, by whom and why, and +// each module on each node whose resolved value moved. Never a secret: config holds none. +const ConfigChanged = "config-changed" + +// Change is a set, a clear, or a move of resolved values with no set behind it (a declaration or an assignment +// changed), as the history and the event say it. +type Change struct { + Action string `json:"action"` + Level *Level `json:"level,omitempty"` + Key string `json:"key,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + Was json.RawMessage `json:"was,omitempty"` + By string `json:"by,omitempty"` + Via string `json:"via,omitempty"` + Why string `json:"why,omitempty"` + // Approved is who approved it on a channel that proves who answered, for a value set on the operator's warrant. + Approved string `json:"approved,omitempty"` + At time.Time `json:"at"` + Moved []Moved `json:"moved"` +} + +// Config is the module: its store, its clock, and one lock, so a set and a resolution never interleave. +type Config struct { + Store Store + Now func() time.Time + Logf func(format string, a ...any) + + mu sync.Mutex + // withheld is why the newest list of predefined keys was not published, empty when it was (publish). + withheld string + + // Asks is the operator channel as this module asks on it (proposals.go); nil, nothing can be proposed. + Asks AskBus + // RouterWithin and RouterEvery bound how long a proposal waits for the router's word on its ask, and how often + // it looks; zero is RouterAnswersWithin and RouterAnswersEvery. + RouterWithin, RouterEvery time.Duration + endedMu sync.Mutex + ended map[string]endedWord +} + +// Call is one call of the verb, its arguments read. +type Call struct { + Action string + Key string + Value any + Module string + Node string + Why string + // Caller is the bus user the runtime proved; By is who gave the value at the controller's terminal, said by the + // controller alone. + Caller string + By string + Limit int + // Clear asks a proposal to clear the value instead of setting it; Proposal names one proposal to read. + Clear bool + Proposal string + + // approval is the operator's warrant a set or a clear is made on: given by a decided proposal alone, never by + // a call's arguments. + approval *approval +} + +// SetAnswer is what a set or a clear answers. +type SetAnswer struct { + Said string `json:"said"` + Level Level `json:"level"` + Key string `json:"key"` + Value json.RawMessage `json:"value,omitempty"` + Was json.RawMessage `json:"was,omitempty"` + Moved []Moved `json:"moved"` + Result []NodeResult `json:"result"` + // Unsaid is what could not be recorded or announced after the value was written: said, never dropped. + Unsaid []string `json:"unsaid,omitempty"` +} + +// NodeResult is one affected node's result. Until the provisioner client fills files from mesh-config_resolved +// (design 57 P5, P6), a module on a node takes its new value at its next composition; that is said, not hidden. +type NodeResult struct { + Node string `json:"node"` + Modules []string `json:"modules"` + Result string `json:"result"` +} + +// Do runs one call of the verb. +func (c *Config) Do(call Call) (any, error) { + switch call.Action { + case "", "list": + return c.List(call) + case "set", "clear": + return c.Change(call) + case "history": + return c.History(call) + case "propose": + return c.Propose(call) + case "proposals": + return c.Proposals(call) + } + return nil, fmt.Errorf("config does set, clear, list, history, propose or proposals; %q is none of them", call.Action) +} + +// level is the level a call names, refused as ADR 0348 §3 says: a module's own value is for one node. +func (call Call) level() (Level, error) { + for _, name := range []string{call.Node, call.Module} { + if name != "" && !nodeOrModule.MatchString(name) { + return Level{}, fmt.Errorf("%q is not a node's or a module's name", name) + } + } + switch { + case call.Module != "" && call.Node != "": + return Level{Kind: LevelModule, Node: call.Node, Module: call.Module}, nil + case call.Module != "": + return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+ + "module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+ + "predefined key %s's value falls back to, or change its default in the catalogue", call.Module) + case call.Node != "": + return Level{Kind: LevelNode, Node: call.Node}, nil + } + return Level{Kind: LevelMesh}, nil +} + +// Change sets or clears one value. +func (c *Config) Change(call Call) (*SetAnswer, error) { + if call.Clear || call.Proposal != "" { + return nil, fmt.Errorf("clear and proposal are a proposal's: %s takes neither", call.Action) + } + call.approval = nil // only a decided proposal gives one + c.mu.Lock() + defer c.mu.Unlock() + return c.change(call) +} + +// change sets or clears one value; the caller holds the lock. +func (c *Config) change(call Call) (*SetAnswer, error) { + level, err := call.level() + if err != nil { + return nil, err + } + if !keyName.MatchString(call.Key) { + return nil, fmt.Errorf("name the key to %s: lower-case letters, digits, dots, dashes and underscores", call.Action) + } + if strings.TrimSpace(call.Why) == "" { + return nil, fmt.Errorf("say why the value is %s: every change keeps its why (novox/hq ADR 0348 §9)", + map[string]string{"set": "set", "clear": "cleared"}[call.Action]) + } + if call.Caller == "" || call.Caller == Unproven { + return nil, errors.New("the call names no proven caller, and every change keeps who made it " + + "(novox/hq ADR 0348 §9): call config through the mesh, which names you") + } + by := call.Caller + if call.By != "" { + if call.Caller != ControllerCaller { + return nil, fmt.Errorf("by names who gave a value at the controller's terminal, which only the controller "+ + "says; %s is not the controller", call.Caller) + } + by = call.By + } + s, err := c.sources() + if err != nil { + return nil, err + } + r, trusted, err := c.declared(s, level, call.Key) + if err != nil { + return nil, err + } + vk := level.ValueKey(call.Key) + was, had := s.Values[vk] + if call.approval != nil { + // **On Approve, the value stored must still be the one the operator was shown the change against** (ADR + // 0348 §8, ADR 0277 §13): a value set or cleared since, at the terminal or on another warrant, refuses it. + if had != call.approval.HadValue || valueDigest(was.Value, had) != call.approval.WasDigest { + return nil, fmt.Errorf("%s at %s is %s now, and the operator was shown the change against %s: it changed "+ + "since, so nothing is set; propose it again", call.Key, level, shownOrNone(was.Value, had), + shownOrNone(call.approval.Was, call.approval.HadValue)) + } + by = call.approval.By + } + answer := &SetAnswer{Level: level, Key: call.Key} + if had { + answer.Was = compact(was.Value) + } + now := c.now() + switch call.Action { + case "set": + if call.Value == nil { + return nil, fmt.Errorf("give the value to set %s to", call.Key) + } + raw, err := json.Marshal(call.Value) + if err != nil { + return nil, fmt.Errorf("the value cannot be read: %v", err) + } + var value any + _ = json.Unmarshal(raw, &value) + if problems := r.check(value); len(problems) > 0 { + return nil, fmt.Errorf("%s is refused for %s at %s: %s", show(value), call.Key, level, + strings.Join(problems, "; ")) + } + if trusted && call.Caller != ControllerCaller && call.approval == nil { + return nil, fmt.Errorf("%s is trusted (it decides what runs or who can reach what), so it is set only at "+ + "the controller's terminal or on the operator's Approve (novox/hq ADR 0348 §8); %s is not the terminal: "+ + "propose it instead, with config's action propose", call.Key, call.Caller) + } + v := Value{Value: compact(raw), By: by, Via: call.Caller, Why: strings.TrimSpace(call.Why), At: now} + if call.approval != nil { + v.Approved = call.approval.Says + } + if err := c.Store.Put(ValuesState, vk, v); err != nil { + return nil, fmt.Errorf("the value was not written: %v", err) + } + s.Values[vk] = v + answer.Value = v.Value + case "clear": + if !had { + return nil, fmt.Errorf("nothing is set for %s at %s, so there is nothing to clear", call.Key, level) + } + if trusted && call.Caller != ControllerCaller && call.approval == nil { + return nil, fmt.Errorf("%s is trusted, so it is cleared only at the controller's terminal or on the "+ + "operator's Approve (novox/hq ADR 0348 §8); %s is not the terminal: propose the clear instead, with "+ + "config's action propose and clear", call.Key, call.Caller) + } + if err := c.Store.Delete(ValuesState, vk); err != nil { + return nil, fmt.Errorf("the value was not cleared: %v", err) + } + delete(s.Values, vk) + } + moved, err := c.writeResolved(s) + answer.Moved = moved + if err != nil { + // The value is written and the resolved values are not all: said, and put right on the next resolution. + answer.Unsaid = append(answer.Unsaid, "the resolved values are not all written yet ("+err.Error()+"); "+ + "the next resolution writes them") + } + change := Change{Action: call.Action, Level: &level, Key: call.Key, Value: answer.Value, Was: answer.Was, By: by, + Via: call.Caller, Why: strings.TrimSpace(call.Why), At: now, Moved: moved} + cause := "the config verb, called by " + call.Caller + if call.approval != nil { + change.Approved = call.approval.Says + cause = "the operator's Approve of proposal " + call.approval.Proposal + ", proposed by " + call.Caller + } + answer.Unsaid = append(answer.Unsaid, c.record(vk, change)...) + if err := c.Store.HandAct(handActWhat(change), change.Why, cause); err != nil { + answer.Unsaid = append(answer.Unsaid, "the hand-act log does not hold it: "+err.Error()) + } + answer.Result = nodeResults(moved) + answer.Said = said(change) + return answer, nil +} + +// declared is the rule a value at a level is held to, and whether it is trusted; refused, naming the declared +// keys, for a key the module or the list of predefined keys does not declare (ADR 0348 §10). +func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error) { + if level.Kind == LevelModule { + decl, ok := s.Declared[level.Module] + if !ok { + return rule{}, false, fmt.Errorf("%s is not a registered module, so it declares no config", level.Module) + } + d, ok := decl.Config[key] + if !ok { + return rule{}, false, fmt.Errorf("%s declares no value %q; it declares: %s", level.Module, key, + joinKeys(decl.Config)) + } + if !assigned(s, level.Node, level.Module) { + return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+ + "is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node, + strings.Join(nodesOf(s, level.Module), ", ")) + } + trusted := d.Trusted + if p, ok := s.Predefined[d.Predefined]; ok && p.Trusted { + trusted = true + } + return d.rule(), trusted, nil + } + if len(s.Predefined) == 0 { + return rule{}, false, fmt.Errorf("%s holds values only for the keys of the list of predefined keys, and no "+ + "list is published yet (novox/hq ADR 0348 §7)", level) + } + p, ok := s.Predefined[key] + if !ok { + return rule{}, false, fmt.Errorf("%s holds values only for the predefined keys, and %q is not one; the "+ + "predefined keys are: %s (novox/hq ADR 0348 §7)", level, key, joinKeys(s.Predefined)) + } + return p.rule(), p.Trusted, nil +} + +func assigned(s Sources, node, module string) bool { + for _, a := range s.Assignments { + if a.Node == node && a.Module == module { + return true + } + } + return false +} + +func nodesOf(s Sources, module string) []string { + var out []string + for _, a := range s.Assignments { + if a.Module == module { + out = append(out, a.Node) + } + } + sort.Strings(out) + if len(out) == 0 { + return []string{"no node"} + } + return out +} + +// record keeps a change in the history and raises its event; what could not be done is answered, not dropped. +func (c *Config) record(key string, change Change) []string { + var unsaid []string + if err := c.Store.Append(HistoryLog, key, change); err != nil { + unsaid = append(unsaid, "the history does not hold it: "+err.Error()) + } + if err := c.Store.Emit(ConfigChanged, change); err != nil { + unsaid = append(unsaid, "config-changed was not raised: "+err.Error()) + } + for _, u := range unsaid { + c.logf("[mesh-config] %s %s: %s", change.Action, key, u) + } + return unsaid +} + +func handActWhat(ch Change) string { + what := fmt.Sprintf("config %s %s at %s", ch.Action, ch.Key, ch.Level) + if ch.Action == "set" { + what += " to " + string(ch.Value) + } + return what + ", by " + ch.By +} + +func said(ch Change) string { + var b strings.Builder + if ch.Action == "set" { + fmt.Fprintf(&b, "%s is %s at %s", ch.Key, ch.Value, ch.Level) + } else { + fmt.Fprintf(&b, "%s is cleared at %s", ch.Key, ch.Level) + } + switch len(ch.Moved) { + case 0: + b.WriteString("; no module's value moved") + case 1: + m := ch.Moved[0] + fmt.Fprintf(&b, "; %s on %s takes %s (from %s)", m.Module, m.Node, orUnset(m.To), m.Source) + default: + fmt.Fprintf(&b, "; %d values moved on %d node(s)", len(ch.Moved), len(nodeResults(ch.Moved))) + } + return b.String() +} + +func orUnset(raw json.RawMessage) string { + if len(raw) == 0 { + return "no value (unset)" + } + return string(raw) +} + +func nodeResults(moved []Moved) []NodeResult { + byNode := map[string]map[string]bool{} + for _, m := range moved { + if byNode[m.Node] == nil { + byNode[m.Node] = map[string]bool{} + } + byNode[m.Node][m.Module] = true + } + out := []NodeResult{} + for _, node := range sortedKeys(byNode) { + out = append(out, NodeResult{Node: node, Modules: sortedKeys(byNode[node]), + Result: "resolved and written to mesh-config_resolved; the module takes it when its files are filled again"}) + } + return out +} + +func (c *Config) now() time.Time { + if c.Now == nil { + return time.Now().UTC() + } + return c.Now().UTC() +} + +func (c *Config) logf(format string, a ...any) { + if c.Logf != nil { + c.Logf(format, a...) + } +} diff --git a/modules/mesh-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go new file mode 100644 index 00000000..e2418422 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -0,0 +1,553 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log, +// the events it raised and the hand-acts it recorded. +type memStore struct { + mu sync.Mutex + states map[string]map[string][]byte + logs map[string][]LogEntry + events []Change + handActs []string + // failHandAct makes the hand-act log refuse. + failHandAct bool +} + +func newStore() *memStore { + return &memStore{states: map[string]map[string][]byte{}, logs: map[string][]LogEntry{}} +} + +func (m *memStore) Get(state, key string) (*Entry, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.states[state][key] + if !ok { + return nil, nil + } + return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil +} + +func (m *memStore) Put(state, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + if m.states[state] == nil { + m.states[state] = map[string][]byte{} + } + m.states[state][key] = raw + return nil +} + +func (m *memStore) Delete(state, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.states[state], key) + return nil +} + +func (m *memStore) Keys(state string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + var keys []string + for k := range m.states[state] { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +func (m *memStore) Append(log, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + m.logs[log] = append(m.logs[log], LogEntry{Key: key, Value: raw, At: time.Now()}) + return nil +} + +func (m *memStore) Read(log string) ([]LogEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]LogEntry(nil), m.logs[log]...), nil +} + +func (m *memStore) Emit(event string, body any) error { + if event != ConfigChanged { + return fmt.Errorf("mesh-config emits no %s", event) + } + m.mu.Lock() + defer m.mu.Unlock() + m.events = append(m.events, body.(Change)) + return nil +} + +func (m *memStore) HandAct(what, why, cause string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.failHandAct { + return errors.New("the controller did not answer") + } + m.handActs = append(m.handActs, what+" | "+why+" | "+cause) + return nil +} + +// mirror writes what the controller mirrors, in the shapes of its mirrors. +func (m *memStore) mirror(t *testing.T, read, key, raw string) { + t.Helper() + if !json.Valid([]byte(raw)) { + t.Fatalf("not JSON: %s", raw) + } + m.mu.Lock() + defer m.mu.Unlock() + if m.states[read] == nil { + m.states[read] = map[string][]byte{} + } + m.states[read][key] = []byte(raw) +} + +func (m *memStore) assign(t *testing.T, node, module string) { + m.mirror(t, AssignmentsRead, node+"."+module, + fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module)) +} + +func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { + t.Helper() + e, _ := m.Get(ResolvedState, ResolvedKey(node, module, key)) + if e == nil { + t.Fatalf("%s holds no resolved value for %s on %s", key, module, node) + } + var r Resolved + if err := json.Unmarshal(e.Value, &r); err != nil { + t.Fatal(err) + } + return r +} + +// dunstDeclared is dunst's declarations as the declared-config mirror on the feature branch writes them: kind, why +// and default always written, so a config-form key without a default comes as kind "", why "" and default null. +const dunstDeclared = `{"module":"dunst","settings":{ + "font-size":{"kind":"","default":10,"why":"","type":"number","description":"text size, in points","minimum":6,"maximum":72,"predefined":"font-size"}, + "width":{"kind":"","default":300,"why":"","type":"number","description":"the width of a notification, in pixels","minimum":100}, + "position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]}, + "origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}` + +// theList is the controller's predefined mirror, as it writes it. +const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}, + "font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}, + "repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}` + +func aMesh(t *testing.T) (*Config, *memStore) { + t.Helper() + m := newStore() + m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared) + m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{ + "managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`) + m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) + m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) + for _, node := range []string{"g14", "novox"} { + m.assign(t, node, "dunst") + } + m.assign(t, "g14", "claude-code") + m.assign(t, "g14", "old") + clock := time.Date(2026, 10, 14, 19, 0, 0, 0, time.UTC) + c := &Config{Store: m, Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Logf: t.Logf} + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + m.events = nil // the first resolution's + return c, m +} + +func set(t *testing.T, c *Config, call Call) *SetAnswer { + t.Helper() + if call.Action == "" { + call.Action = "set" + } + if call.Caller == "" { + call.Caller = "g14.node-tools" + } + if call.Why == "" { + call.Why = "the operator asked for it" + } + a, err := c.Change(call) + if err != nil { + t.Fatalf("%s %s: %v", call.Action, call.Key, err) + } + return a +} + +func expect(t *testing.T, m *memStore, node string, value, source string) { + t.Helper() + r := m.resolved(t, node, "dunst", "font-size") + if string(r.Value) != value || r.Source != source { + t.Errorf("dunst's font-size on %s is %s from %s, want %s from %s", node, r.Value, r.Source, value, source) + } +} + +// **The four sources, each in turn** (ADR 0348 §2, How it is checked): the manifest's default; mesh config for the +// predefined key it falls back to; node config for that key on the node; the module's own value on that node. Each +// cleared falls back to the one below. +func TestEachOfTheFourSourcesResolvesInTurnAndEachClearedFallsBack(t *testing.T) { + c, m := aMesh(t) + expect(t, m, "g14", "10", SourceDefault) + expect(t, m, "novox", "10", SourceDefault) + + a := set(t, c, Call{Key: "font-size", Value: 12.0}) + expect(t, m, "g14", "12", SourceMesh) + expect(t, m, "novox", "12", SourceMesh) + if len(a.Moved) != 2 || a.Level.Kind != LevelMesh { + t.Errorf("a mesh value moved %+v", a.Moved) + } + + a = set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"}) + expect(t, m, "g14", "14", SourceNode) + expect(t, m, "novox", "12", SourceMesh) + if len(a.Moved) != 1 || a.Moved[0].Node != "g14" || string(a.Moved[0].From) != "12" || string(a.Moved[0].To) != "14" { + t.Errorf("g14's node value moved %+v", a.Moved) + } + + a = set(t, c, Call{Key: "font-size", Value: 16.0, Node: "g14", Module: "dunst"}) + expect(t, m, "g14", "16", SourceModule) + expect(t, m, "novox", "12", SourceMesh) + if len(a.Moved) != 1 || a.Moved[0].Source != SourceModule { + t.Errorf("dunst's own value moved %+v", a.Moved) + } + + // A value set below the module's own moves nothing it overrides. + if a = set(t, c, Call{Key: "font-size", Value: 18.0, Node: "g14"}); len(a.Moved) != 0 { + t.Errorf("a node value under a module's own moved %+v", a.Moved) + } + + set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14", Module: "dunst"}) + expect(t, m, "g14", "18", SourceNode) + set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"}) + expect(t, m, "g14", "12", SourceMesh) + set(t, c, Call{Action: "clear", Key: "font-size"}) + expect(t, m, "g14", "10", SourceDefault) + expect(t, m, "novox", "10", SourceDefault) +} + +// **A value naming no predefined key ignores mesh and node config** (ADR 0348 §2): it resolves from its default and +// the module's own value only. +func TestAValueNamingNoPredefinedKeyResolvesFromItsDefaultAndItsOwnValueOnly(t *testing.T) { + c, m := aMesh(t) + set(t, c, Call{Key: "font-size", Value: 12.0}) + set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"}) + if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "300" || r.Source != SourceDefault { + t.Errorf("width is %s from %s, want its default", r.Value, r.Source) + } + set(t, c, Call{Key: "width", Value: 400.0, Node: "g14", Module: "dunst"}) + if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "400" || r.Source != SourceModule { + t.Errorf("width is %s from %s, want dunst's own value", r.Value, r.Source) + } + // A module not yet moved, in the settings form: its default and its own value. + if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "4" || r.Source != SourceDefault { + t.Errorf("old's workers is %s from %s", r.Value, r.Source) + } + set(t, c, Call{Key: "workers", Value: 8.0, Node: "g14", Module: "old"}) + if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "8" || r.Source != SourceModule { + t.Errorf("old's workers is %s from %s", r.Value, r.Source) + } +} + +// **A required value nothing sets is answered unset** (ADR 0348 §4), and once set, it is the value. +func TestARequiredValueNothingSetsIsAnsweredUnset(t *testing.T) { + c, m := aMesh(t) + r := m.resolved(t, "g14", "dunst", "origin") + if r.Source != SourceUnset || !r.Required || len(r.Value) != 0 { + t.Errorf("an unset required value resolves as %+v", r) + } + set(t, c, Call{Key: "origin", Value: "home", Node: "g14", Module: "dunst"}) + if r = m.resolved(t, "g14", "dunst", "origin"); r.Source != SourceModule || string(r.Value) != `"home"` { + t.Errorf("a set required value resolves as %+v", r) + } + if r = m.resolved(t, "novox", "dunst", "origin"); r.Source != SourceUnset { + t.Errorf("another node's required value resolves as %+v", r) + } +} + +// **The verb refuses** (ADR 0348 §3, §8, §10, How it is checked): a key not declared, naming the declared keys; a +// module value without a node; a value failing validation; a trusted value without the terminal; a change without +// why or a proven caller. +func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) { + c, m := aMesh(t) + for _, tc := range []struct { + name string + call Call + want string + }{ + {"a key the module does not declare", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"}, + "it declares: font-size, origin, position, width"}, + {"a key not on the predefined list, at the mesh", Call{Key: "width", Value: 400.0}, + "the predefined keys are: agent-managed-settings, font-size"}, + {"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"}, + {"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, + "no module value for the whole mesh"}, + {"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, + "it is assigned to: g14, novox"}, + {"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"}, + {"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`}, + {"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"}, + {"a value above its maximum", Call{Key: "font-size", Value: 99.0, Node: "g14", Module: "dunst"}, "above the maximum 72"}, + {"a value not in its enum", Call{Key: "position", Value: "middle", Node: "g14", Module: "dunst"}, "is not one of"}, + {"a value not matching its pattern", Call{Key: "origin", Value: "Home 1", Node: "g14", Module: "dunst"}, + "does not match the pattern"}, + {"a trusted value at the mesh, not from the terminal", Call{Key: "agent-managed-settings", + Value: map[string]any{"a": 1.0}}, "set only at the controller's terminal"}, + {"a trusted module value, not from the terminal", Call{Key: "managed", Value: map[string]any{}, + Node: "g14", Module: "claude-code"}, "set only at the controller's terminal"}, + {"a change without why", Call{Key: "font-size", Value: 12.0, Why: " "}, "say why"}, + {"an unproven caller", Call{Key: "font-size", Value: 12.0, Caller: Unproven}, "no proven caller"}, + {"by from anyone but the controller", Call{Key: "font-size", Value: 12.0, By: "jochen"}, "only the controller says"}, + {"a clear of nothing set", Call{Action: "clear", Key: "font-size", Node: "g14"}, "nothing is set"}, + } { + t.Run(tc.name, func(t *testing.T) { + call := tc.call + if call.Action == "" { + call.Action = "set" + } + if call.Caller == "" { + call.Caller = "g14.node-tools" + } + if call.Why == "" { + call.Why = "a test" + } + _, err := c.Change(call) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("refused with %v, want %q", err, tc.want) + } + }) + } + if keys, _ := m.Keys(ValuesState); len(keys) != 0 { + t.Errorf("a refused call wrote %v", keys) + } + if len(m.events) != 0 || len(m.handActs) != 0 { + t.Errorf("a refused call raised %d events and recorded %d hand-acts", len(m.events), len(m.handActs)) + } +} + +// **A trusted value from the controller's terminal is set**, with who gave it there (ADR 0348 §8). +func TestATrustedValueFromTheTerminalIsSetWithWhoGaveIt(t *testing.T) { + c, m := aMesh(t) + a := set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0, "a": 1.0}, + Caller: ControllerCaller, By: "jochen"}) + if string(a.Value) != `{"a":1,"b":2}` { + t.Errorf("the value is written as %s", a.Value) + } + r := m.resolved(t, "g14", "claude-code", "managed") + if r.Source != SourceMesh || !r.Trusted || string(r.Value) != `{"a":1,"b":2}` { + t.Errorf("claude-code's managed settings resolve as %+v", r) + } + e, _ := m.Get(ValuesState, "mesh.agent-managed-settings") + var v Value + _ = json.Unmarshal(e.Value, &v) + if v.By != "jochen" || v.Via != ControllerCaller { + t.Errorf("the value says it was set by %s via %s", v.By, v.Via) + } +} + +// **Every set and clear is in the history and the hand-act log with the proven caller and why, and raises +// config-changed naming what moved** (ADR 0348 §9, §10). +func TestEveryChangeIsKeptWithWhoAndWhyAndRaisesConfigChanged(t *testing.T) { + c, m := aMesh(t) + m.events = nil + set(t, c, Call{Key: "font-size", Value: 12.0, Caller: "novox.node-tools", Why: "bigger on every desk"}) + set(t, c, Call{Action: "clear", Key: "font-size", Caller: "g14.node-tools", Why: "back to the default"}) + + history, err := c.History(Call{}) + if err != nil { + t.Fatal(err) + } + var changes []HistoryEntry + for _, h := range history { + if h.Action == "set" || h.Action == "clear" { + changes = append(changes, h) + } + } + if len(changes) != 2 || changes[0].By != "novox.node-tools" || changes[0].Why != "bigger on every desk" || + changes[1].Action != "clear" || changes[1].By != "g14.node-tools" || string(changes[1].Was) != "12" { + t.Fatalf("the history holds %+v", changes) + } + if len(m.handActs) != 2 || !strings.Contains(m.handActs[0], "config set font-size at mesh config to 12, by novox.node-tools | bigger on every desk") { + t.Errorf("the hand-act log holds %q", m.handActs) + } + if len(m.events) != 2 || len(m.events[0].Moved) != 2 || m.events[0].Key != "font-size" || m.events[1].Action != "clear" { + t.Errorf("config-changed was raised as %+v", m.events) + } + // Narrowed: g14's dunst sees both, through what they moved; a node that holds no dunst sees neither. + if h, _ := c.History(Call{Module: "dunst", Node: "g14"}); len(h) < 2 { + t.Errorf("dunst on g14's history holds %d changes", len(h)) + } + if h, _ := c.History(Call{Node: "ace"}); len(h) != 0 { + t.Errorf("ace's history holds %+v", h) + } +} + +// **A hand-act the log refuses is said in the answer, never dropped**, and the value is set. +func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) { + c, m := aMesh(t) + m.failHandAct = true + a := set(t, c, Call{Key: "font-size", Value: 12.0}) + if len(a.Unsaid) != 1 || !strings.Contains(a.Unsaid[0], "hand-act log does not hold it") { + t.Errorf("the answer says %q", a.Unsaid) + } + expect(t, m, "g14", "12", SourceMesh) +} + +// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7). +func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) { + c, m := aMesh(t) + published := func() map[string]Published { + out := map[string]Published{} + keys, _ := m.Keys(PredefinedState) + for _, k := range keys { + e, _ := m.Get(PredefinedState, k) + var p Published + _ = json.Unmarshal(e.Value, &p) + out[k] = p + } + return out + } + if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted { + t.Fatalf("published %+v", p) + } + m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"}, + "theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" { + t.Fatalf("a newer list published %+v", p) + } + m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + if p := published(); p["theme"].Generation != 7 { + t.Fatalf("an older list replaced the newer one: %+v", p) + } +} + +// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the +// listing says why until the value is cleared. +func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) { + c, m := aMesh(t) + set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"}) + m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object", + "description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + listed, err := c.List(Call{}) + if err != nil { + t.Fatal(err) + } + if _, kept := listed.Predefined["font-size"]; !kept || + !strings.Contains(listed.Withheld, "drops font-size (still set as node config on novox)") { + t.Fatalf("the list was published over a value it drops: withheld %q, published %v", listed.Withheld, + joinKeys(listed.Predefined)) + } + set(t, c, Call{Action: "clear", Key: "font-size", Node: "novox"}) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + listed, _ = c.List(Call{}) + if _, kept := listed.Predefined["font-size"]; kept || listed.Withheld != "" { + t.Errorf("once cleared, the list is still withheld: %q, %v", listed.Withheld, joinKeys(listed.Predefined)) + } +} + +// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and +// said as config-changed; a pass over unchanged sources writes nothing. +func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) { + c, m := aMesh(t) + r, err := c.Reconcile() + if err != nil || len(r.Moved) != 0 { + t.Fatalf("a pass over unchanged sources moved %+v (%v)", r.Moved, err) + } + m.events = nil + m.assign(t, "ace", "dunst") + if r, _ = c.Reconcile(); len(r.Moved) != 4 { + t.Errorf("an assignment moved %+v", r.Moved) + } + expect(t, m, "ace", "10", SourceDefault) + if len(m.events) != 1 || m.events[0].Action != "resolve" { + t.Errorf("an assignment raised %+v", m.events) + } + _ = m.Delete(AssignmentsRead, "ace.dunst") + if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { + t.Errorf("an unassignment moved %+v", r.Moved) + } + if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil { + t.Error("an unassigned module keeps its resolved values") + } +} + +// **The declared-config mirror's two shapes read the same** (the release lead's note on issue 652): kind, why and a +// null default written for a config-form key are not given, and a mirror that leaves them out reads alike. +func TestTheDeclaredConfigMirrorsTwoShapesReadTheSame(t *testing.T) { + with, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"kind":"","default":null,"why":"","type":"string","description":"d","required":true}}}`)) + if err != nil { + t.Fatal(err) + } + without, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"type":"string","description":"d","required":true}}}`)) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(with) + b, _ := json.Marshal(without) + if string(a) != string(b) || with.Config["k"].HasDefault() { + t.Errorf("the two shapes read as %s and %s", a, b) + } + if r := resolveOne(Sources{}, "n", "x", "k", with.Config["k"]); r.Source != SourceUnset { + t.Errorf("a null default resolved as %+v", r) + } +} + +// **A listing narrows by module and node**, and says the mesh and node values its modules fall back to. +func TestAListingNarrowsByModuleAndNode(t *testing.T) { + c, _ := aMesh(t) + set(t, c, Call{Key: "font-size", Value: 12.0}) + set(t, c, Call{Key: "font-size", Value: 14.0, Node: "novox"}) + l, err := c.List(Call{Module: "dunst", Node: "g14"}) + if err != nil { + t.Fatal(err) + } + if len(l.Resolved) != 4 || len(l.Set) != 1 || l.Set[0].Level.Kind != LevelMesh || l.Predefined != nil { + t.Errorf("dunst on g14 lists %d resolved and %+v", len(l.Resolved), l.Set) + } + l, _ = c.List(Call{Node: "novox"}) + if len(l.Resolved) != 4 || len(l.Set) != 2 { + t.Errorf("novox lists %d resolved and %+v", len(l.Resolved), l.Set) + } +} + +// **The verb's arguments are read strictly**: one it does not know is refused, naming what it takes. +func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) { + if _, err := callOf(map[string]any{"action": "set", "level": "mesh"}); err == nil || + !strings.Contains(err.Error(), "config takes no level") { + t.Errorf("an unknown argument: %v", err) + } + if _, err := callOf(map[string]any{"key": 3.0}); err == nil { + t.Error("a key that is not a string was read") + } + call, err := callOf(map[string]any{"action": "set", "key": "font-size", "value": 12.0, "why": " w "}) + if err != nil || call.Value != 12.0 || call.Why != "w" { + t.Errorf("read %+v (%v)", call, err) + } +} diff --git a/modules/mesh-config/cmd/mesh-config/declared.go b/modules/mesh-config/cmd/mesh-config/declared.go new file mode 100644 index 00000000..6cf9f63f --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/declared.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "slices" + "sort" + "strings" +) + +// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined +// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3). + +// The types a config value may have (ADR 0348 §4). +const ( + TypeNumber = "number" + TypeString = "string" + TypeBoolean = "boolean" + TypeObject = "object" + TypeList = "list" +) + +// Declaration is one value a module declares, as the declared-config mirror holds it: the config form's fields, +// or the settings form's kind, default and why for a module not yet moved (ADR 0348 §1). The mirror writes kind, +// default and why for every key, so a config-form key comes as kind "", why "" and default null: an empty kind or +// why and a null default are read as not given, never as an empty value or a null default. +type Declaration struct { + Kind string `json:"kind,omitempty"` + Default json.RawMessage `json:"default,omitempty"` + Why string `json:"why,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Trusted bool `json:"trusted,omitempty"` + Predefined string `json:"predefined,omitempty"` + Required bool `json:"required,omitempty"` + Runtime bool `json:"runtime,omitempty"` +} + +// HasDefault is whether the declaration gives a default: present and not null. +func (d Declaration) HasDefault() bool { + return len(bytes.TrimSpace(d.Default)) > 0 && string(bytes.TrimSpace(d.Default)) != "null" +} + +// TypeOf is the value's type: the config form's, or for the settings form the type of its default. +func (d Declaration) TypeOf() string { + if d.Type != "" { + return d.Type + } + if !d.HasDefault() { + return "" + } + var v any + if json.Unmarshal(d.Default, &v) != nil { + return "" + } + return typeOfValue(v) +} + +// ModuleDeclarations is one module's value of the declared-config mirror. +type ModuleDeclarations struct { + Module string `json:"module"` + Config map[string]Declaration `json:"settings"` +} + +// ParseModuleDeclarations reads one key of the declared-config mirror. +func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) { + var m ModuleDeclarations + if err := json.Unmarshal(raw, &m); err != nil { + return m, fmt.Errorf("not a module's declarations: %w", err) + } + if m.Module == "" { + return m, fmt.Errorf("a module's declarations that name no module") + } + if m.Config == nil { + m.Config = map[string]Declaration{} + } + for key, d := range m.Config { + if !d.HasDefault() { + d.Default = nil + } + m.Config[key] = d + } + return m, nil +} + +// PredefinedKey is one key of the list of predefined keys (ADR 0348 §7): its type, description, validation and +// whether it is trusted. It has no default. +type PredefinedKey struct { + Type string `json:"type"` + Description string `json:"description"` + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Trusted bool `json:"trusted,omitempty"` +} + +// PredefinedList is the list the controller kept, as its predefined mirror holds it under PredefinedMirrorKey. +type PredefinedList struct { + Keys map[string]PredefinedKey `json:"keys"` + Repository string `json:"repository"` + Commit string `json:"commit"` + Generation int `json:"generation"` + Build string `json:"build"` +} + +// PredefinedMirrorKey is the controller's predefined mirror's one key. +const PredefinedMirrorKey = "list" + +// Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from. +type Published struct { + PredefinedKey + Generation int `json:"generation"` + Commit string `json:"commit"` +} + +// Assignment is one key of the assignments mirror: a module on a node. +type Assignment struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// rule is the validation a value is held to, from a module's declaration or a predefined key. +type rule struct { + Type string + Enum []any + Minimum *float64 + Maximum *float64 + Pattern string +} + +func (d Declaration) rule() rule { + return rule{Type: d.TypeOf(), Enum: d.Enum, Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern} +} + +func (k PredefinedKey) rule() rule { + return rule{Type: k.Type, Enum: k.Enum, Minimum: k.Minimum, Maximum: k.Maximum, Pattern: k.Pattern} +} + +// check is every way value breaks the rule, said in a sentence each; none for a value that holds. +func (r rule) check(value any) []string { + var problems []string + if r.Type != "" && typeOfValue(value) != r.Type { + return []string{fmt.Sprintf("%s is not a %s", show(value), r.Type)} + } + if len(r.Enum) > 0 && !slices.ContainsFunc(r.Enum, func(e any) bool { return sameValue(e, value) }) { + problems = append(problems, fmt.Sprintf("%s is not one of %s", show(value), show(r.Enum))) + } + if n, ok := value.(float64); ok { + if r.Minimum != nil && n < *r.Minimum { + problems = append(problems, fmt.Sprintf("%s is below the minimum %s", show(n), show(*r.Minimum))) + } + if r.Maximum != nil && n > *r.Maximum { + problems = append(problems, fmt.Sprintf("%s is above the maximum %s", show(n), show(*r.Maximum))) + } + if math.IsNaN(n) || math.IsInf(n, 0) { + problems = append(problems, "a number is finite") + } + } + if s, ok := value.(string); ok && r.Pattern != "" { + pattern, err := regexp.Compile(`^(?:` + r.Pattern + `)$`) + switch { + case err != nil: + problems = append(problems, fmt.Sprintf("its pattern %q cannot be read: %v", r.Pattern, err)) + case !pattern.MatchString(s): + problems = append(problems, fmt.Sprintf("%q does not match the pattern %q", s, r.Pattern)) + } + } + return problems +} + +// typeOfValue is the config type of a value read from JSON. +func typeOfValue(v any) string { + switch v.(type) { + case float64: + return TypeNumber + case string: + return TypeString + case bool: + return TypeBoolean + case map[string]any: + return TypeObject + case []any: + return TypeList + } + return "" +} + +func sameValue(a, b any) bool { + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return bytes.Equal(ra, rb) +} + +func show(v any) string { + raw, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(raw) +} + +// keyName is what a config key may be (the catalogue's rule): lower-case letters, digits, dots, dashes and +// underscores, starting with a letter or digit. +var keyName = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + +// nodeOrModule is what a node or a module name may be: one token of a bucket's key. +var nodeOrModule = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func joinKeys[V any](m map[string]V) string { + keys := sortedKeys(m) + if len(keys) == 0 { + return "none" + } + return strings.Join(keys, ", ") +} diff --git a/modules/mesh-config/cmd/mesh-config/list.go b/modules/mesh-config/cmd/mesh-config/list.go new file mode 100644 index 00000000..a2169023 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/list.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +// Listing the values and reading their history, narrowed by module and node (ADR 0348 §1). + +// Listed is what a listing answers. +type Listed struct { + // Resolved is each module's value on each node the call narrows to, with where it came from. + Resolved []Resolved `json:"resolved"` + // Set is every value set at a level the call reaches, with who set it and why. + Set []SetValue `json:"set"` + // Predefined is the published list of predefined keys, for a call that names no module. + Predefined map[string]Published `json:"predefined,omitempty"` + // Withheld is why the newest list of predefined keys is not published, when it is not. + Withheld string `json:"withheld,omitempty"` +} + +// SetValue is one value set, at its level. +type SetValue struct { + Level Level `json:"level"` + Key string `json:"key"` + Value +} + +// List answers the resolved values and the values set, narrowed by module, node and key. +func (c *Config) List(call Call) (*Listed, error) { + for _, name := range []string{call.Node, call.Module} { + if name != "" && !nodeOrModule.MatchString(name) { + return nil, fmt.Errorf("%q is not a node's or a module's name", name) + } + } + c.mu.Lock() + withheld := c.withheld + c.mu.Unlock() + s, err := c.sources() + if err != nil { + return nil, err + } + if call.Module != "" { + if _, ok := s.Declared[call.Module]; !ok { + return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module) + } + } + out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld} + resolved := Resolve(s) + for _, k := range sortedKeys(resolved) { + r := resolved[k] + if (call.Module == "" || r.Module == call.Module) && (call.Node == "" || r.Node == call.Node) && + (call.Key == "" || r.Key == call.Key || r.Predefined == call.Key) { + out.Resolved = append(out.Resolved, r) + } + } + // The predefined keys the narrowed modules fall back to: their mesh and node values are listed too. + fallsBack := map[string]bool{} + for _, r := range out.Resolved { + if r.Predefined != "" { + fallsBack[r.Predefined] = true + } + } + for _, k := range sortedKeys(s.Values) { + level, key, ok := parseValueKey(k) + if !ok { + continue + } + if call.Key != "" && key != call.Key { + continue + } + reached := false + switch level.Kind { + case LevelMesh: + reached = call.Module == "" || fallsBack[key] + case LevelNode: + reached = (call.Node == "" || level.Node == call.Node) && (call.Module == "" || fallsBack[key]) + case LevelModule: + reached = (call.Node == "" || level.Node == call.Node) && (call.Module == "" || level.Module == call.Module) + } + if reached { + out.Set = append(out.Set, SetValue{Level: level, Key: key, Value: s.Values[k]}) + } + } + if call.Module == "" { + out.Predefined = s.Predefined + } + return out, nil +} + +// HistoryEntry is one change as the history keeps it. +type HistoryEntry struct { + Key string `json:"key"` + Change +} + +// History is the changes the call narrows to, newest last, at most its limit (100 when it gives none). +func (c *Config) History(call Call) ([]HistoryEntry, error) { + entries, err := c.Store.Read(HistoryLog) + if err != nil { + return nil, fmt.Errorf("the history cannot be read: %w", err) + } + limit := call.Limit + if limit <= 0 { + limit = 100 + } + out := []HistoryEntry{} + for _, e := range entries { + var ch Change + if err := json.Unmarshal(e.Value, &ch); err != nil { + continue + } + if !reaches(call, e.Key, ch) { + continue + } + out = append(out, HistoryEntry{Key: e.Key, Change: ch}) + } + if len(out) > limit { + out = out[len(out)-limit:] + } + return out, nil +} + +// reaches is whether a change concerns what the call narrows to: its level, or a value it moved. +func reaches(call Call, key string, ch Change) bool { + if call.Module == "" && call.Node == "" && call.Key == "" { + return true + } + if level, k, ok := parseValueKey(key); ok { + if (call.Key == "" || k == call.Key) && (call.Node == "" || level.Node == call.Node) && + (call.Module == "" || level.Module == call.Module) { + return true + } + } + // A change at another level reaches the call through what it moved there: mesh config moving a module's + // value on the node named. + for _, m := range ch.Moved { + if (call.Module == "" || m.Module == call.Module) && (call.Node == "" || m.Node == call.Node) && + (call.Key == "" || m.Key == call.Key || ch.Key == call.Key) { + return true + } + } + return false +} diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go new file mode 100644 index 00000000..c915d3ef --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -0,0 +1,283 @@ +// mesh-config: the owner of every config value, holder of the mesh-config seat (novox/hq ADR 0348 §9, design 57 +// K3). A Go bundle served by a runtime of its own, on its own bus account (`runs-as`, ADR 0259 §8), so that its +// account alone writes its buckets: mesh-config_values (each value set, at the mesh, a node, or a module on a +// node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the +// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve, +// asked under this module's own name, design 57 K4). +// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept +// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Seat is the role this module holds, once for the mesh, and Verb its one verb. +const ( + Seat = "mesh-config" + Verb = "config" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// runtimeStore is the bus, through the runtime that launched this bundle. +type runtimeStore struct{} + +func (runtimeStore) Get(state, key string) (*Entry, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, err + } + return &Entry{Key: e.Key, Value: e.Value, Revision: e.Revision}, nil +} + +func (runtimeStore) Put(state, key string, value any) error { + _, err := stdio.State(state).Put(key, value) + return err +} + +func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) } + +func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +func (runtimeStore) Append(log, key string, value any) error { + _, err := stdio.Log(log).Append(key, value) + return err +} + +func (runtimeStore) Read(log string) ([]LogEntry, error) { + var out []LogEntry + for from := uint64(0); ; { + page, err := stdio.Log(log).Read(from, "", 1000) + if err != nil { + return nil, err + } + for _, e := range page.Entries { + out = append(out, LogEntry{Key: e.Key, Value: e.Value, At: e.At}) + } + if page.Next == 0 { + return out, nil + } + from = page.Next + } +} + +func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) } + +// HandAct records a change in the controller's hand-act log (to-be 45 §7) through its seat's verb. +func (runtimeStore) HandAct(what, why, cause string) error { + _, err := stdio.Ask("seat:mesh-controller.hand-act", map[string]any{"what": what, "why": why, "cause": cause}) + return err +} + +// runtimeAsks is the operator channel through the runtime: asks published under this module's own name, the +// router's records of them read under that name alone (its `uses` of the seat, ADR 0259 §3), and the controller's +// hand-act log for a warrant acted on. +type runtimeAsks struct{} + +func (runtimeAsks) Publish(subject string, body any, id string) error { + return stdio.SeatPublish(subject, body, id) +} +func (runtimeAsks) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) } +func (runtimeAsks) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() } +func (runtimeAsks) Warranted(ask string) error { + _, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask}) + return err +} + +func main() { + c := &Config{Store: runtimeStore{}, Logf: logf, Asks: runtimeAsks{}} + go resolving(c) + go hearing(c) + if err := stdio.Serve("", Tools(c)); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// resolving resolves at start and again whenever a source moves, and every five minutes besides, so a change +// heard nowhere is still resolved. The runtime answers only once Serve runs, so the watches are asked again +// until they are taken. +func resolving(c *Config) { + moved := make(chan struct{}, 1) + poke := func(stdio.StateChange) error { + select { + case moved <- struct{}{}: + default: + } + return nil + } + for _, read := range []string{AssignmentsRead, DeclaredConfigRead, PredefinedRead} { + go func(read string) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + time.Sleep(wait) + if err := stdio.State(read).Watch("", poke); err != nil { + logf("[mesh-config] %s is not watched yet (%v); asking again in %s", read, err, wait) + continue + } + return + } + }(read) + } + tick := time.NewTicker(5 * time.Minute) + defer tick.Stop() + time.Sleep(time.Second) + for { + r, err := c.Reconcile() + switch { + case err != nil: + logf("[mesh-config] the values are not all resolved: %v", err) + case len(r.Moved) > 0: + logf("[mesh-config] resolved: %d value(s) moved", len(r.Moved)) + } + if r.Published != "" { + logf("[mesh-config] published %s", r.Published) + } + select { + case <-moved: + time.Sleep(500 * time.Millisecond) // a burst of writes is one resolution + select { + case <-moved: + default: + } + case <-tick.C: + } + } +} + +// hearing takes the router's word on this module's asks, asked again until the runtime takes the subscription, and +// reads the router's record of every waiting ask once a minute besides, so an answer heard nowhere is still taken. +func hearing(c *Config) { + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + time.Sleep(wait) + err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w asks.Warrant + if json.Unmarshal(e.Body, &w) == nil { + for _, s := range c.HeardEnd(w) { + logf("[mesh-config] %s", s) + } + } + return nil + }) + if err == nil { + logf("[mesh-config] hears the operator's answers") + return + } + logf("[mesh-config] does not hear the operator's answers yet (%v); asking again in %s", err, wait) + } + }() + tick := time.NewTicker(time.Minute) + defer tick.Stop() + for range tick.C { + said, err := c.Decide() + if err != nil { + logf("[mesh-config] reading the operator's answers: %v", err) + } + for _, s := range said { + logf("[mesh-config] %s", s) + } + } +} + +// Tools is the seat's verb as the runtime serves it. +func Tools(c *Config) []stdio.Tool { + return []stdio.Tool{{ + Name: Seat + "." + Verb, + Description: "Set, clear, list or read the history of config values (novox/hq ADR 0348). A value is set at " + + "one level: mesh config (no module, no node) or node config (a node) for a predefined key, or a " + + "module's own value on one node (module and node). Each set or clear needs why, is validated against " + + "its declaration, names each module on each node whose value moves, and is kept in the history with " + + "who and why. A trusted value is set only at the controller's terminal or on the operator's Approve: " + + "propose asks the operator, under mesh-config's own name on a channel that proves who answered, and sets " + + "the value on Approve if what it was shown against still stands; proposals lists them, or one with " + + "proposal. list and history are narrowed by module, node and key.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "action": map[string]any{"type": "string", + "enum": []string{"list", "set", "clear", "history", "propose", "proposals"}, + "description": "list when not given"}, + "key": map[string]any{"type": "string", "description": "the config key"}, + "value": map[string]any{"description": "for set: the value, of the key's declared type"}, + "module": map[string]any{"type": "string", "description": "a module: its own value, with node"}, + "node": map[string]any{"type": "string", "description": "a node"}, + "why": map[string]any{"type": "string", "description": "for set and clear: why, in a sentence"}, + "by": map[string]any{"type": "string", + "description": "who gave the value at the controller's terminal: said by the controller alone"}, + "limit": map[string]any{"type": "number", "description": "for history: at most this many, newest"}, + "clear": map[string]any{"type": "boolean", + "description": "for propose: propose clearing the value at its level instead of setting one"}, + "proposal": map[string]any{"type": "string", "description": "for proposals: one proposal's id, read whole"}, + }}, + Run: func(args map[string]any) (any, error) { + call, err := callOf(args) + if err != nil { + return nil, err + } + call.Caller = stdio.CallerOf(args) + return c.Do(call) + }, + }} +} + +// callOf reads a call's arguments strictly: an argument it does not know is refused, not ignored. +func callOf(args map[string]any) (Call, error) { + known := map[string]bool{"action": true, "key": true, "value": true, "module": true, "node": true, "why": true, + "by": true, "limit": true, "clear": true, "proposal": true} + var unknown []string + for k := range args { + if !known[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + return Call{}, fmt.Errorf("config takes no %s; it takes action, key, value, module, node, why, by, limit, "+ + "clear and proposal", + strings.Join(unknown, ", ")) + } + str := func(k string) (string, error) { + v, ok := args[k] + if !ok || v == nil { + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string", k) + } + return strings.TrimSpace(s), nil + } + var call Call + var problems []error + for k, into := range map[string]*string{"action": &call.Action, "key": &call.Key, "module": &call.Module, + "node": &call.Node, "why": &call.Why, "by": &call.By, "proposal": &call.Proposal} { + s, err := str(k) + problems = append(problems, err) + *into = s + } + if v, ok := args["limit"]; ok && v != nil { + n, ok := v.(float64) + if !ok || n < 0 || n != float64(int(n)) { + problems = append(problems, errors.New("limit is a whole number")) + } + call.Limit = int(n) + } + if v, ok := args["clear"]; ok && v != nil { + b, ok := v.(bool) + if !ok { + problems = append(problems, errors.New("clear is true or false")) + } + call.Clear = b + } + if v, ok := args["value"]; ok { + // The value as given, a JSON value: a number for a number, never its text; the validation judges it. + call.Value = v + } + return call, errors.Join(problems...) +} diff --git a/modules/mesh-config/cmd/mesh-config/proposals.go b/modules/mesh-config/cmd/mesh-config/proposals.go new file mode 100644 index 00000000..84d53a09 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/proposals.go @@ -0,0 +1,928 @@ +package main + +// A config value proposed for the operator's Approve (novox/hq ADR 0348 §8, ADR 0277's rules with mesh-config as +// the asker; design 57 K4). +// +// Whoever the bus admits may PROPOSE a value at one level — mesh config, node config or a module's own value on a +// node — a trusted one among them, which the verb sets only from the controller's terminal. A proposal changes +// nothing. It is judged as a set judges it, then kept in mesh-config_proposals (which this module's account alone +// writes) with the value whole, the value stored at that level when it was proposed (what the operator is shown +// the change against), who proposed it, why and when. It is asked of the operator **under mesh-config's own name** +// on the operator-channel seat — its own grant, from its manifest's `uses`, and its own cap of open asks — as an +// ask whose two answers, Approve and Decline, are both at the level approve, so only a channel that proves who +// answered carries either. The ask says the level, the key and the value, and that, approved, it takes effect at +// once on every node it reaches; each option binds the proposal's level, key, the digest of the exact value and of +// the value it was shown against. +// +// **On the answer** mesh-config reads the router's own record of its ask (which only the router writes, and this +// module reads under its own name alone), composes the ask again from the proposal and holds the warrant to it: +// the same ask digest, an option it offered, at its level, in time (asks.Warrant.For), and the act the option +// bound is this proposal's (asks.Option.Performs). A record whose value or shown value changed after it was asked +// is refused, and so is an Approve when the value stored at the level is no longer the one the operator was shown +// the change against. Then the value is set exactly as a set at the terminal is, with the same judgement and +// history, and who approved it is kept beside the value; the warrant is recorded in the controller's hand-act log. + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// Module is this module's name: the one the bus grants its asks under. +const Module = "mesh-config" + +// ProposalVerb is the act a proposal's answers bind. +const ProposalVerb = Seat + "." + Verb + +// ProposalLasts is how long the operator has to answer: a day less ten minutes (ADR 0277 §9), under the router's +// bound for an approval. +const ProposalLasts = asks.ApproveLasts - 10*time.Minute + +// How long a proposal waits for the router's word on its new ask, and how often it looks (ADR 0277 §20). +const ( + RouterAnswersWithin = 8 * time.Second + RouterAnswersEvery = 250 * time.Millisecond +) + +// The two answers, both at the level approve. +const ( + OptionApprove = "approve" + OptionDecline = "decline" +) + +// Where a proposal stands. +const ( + ProposalWaiting = "waiting" + ProposalApproved = "approved" + ProposalDeclined = "declined" + ProposalEnded = "ended" + ProposalRefused = "refused" + ProposalUnasked = "not asked" +) + +// AskBus is the operator channel as this module reaches it: publish an ask under its own name, read the router's +// record of one of its asks, the kinds of the channel bench with what each promises, and the controller's +// hand-act log for a warrant acted on. +type AskBus interface { + Publish(subject string, body any, id string) error + Record(id string) (*stdio.StateEntry, error) + Kinds() ([]stdio.KindHeld, error) + Warranted(ask string) error +} + +// Proposal is one value proposed, as mesh-config_proposals keeps it. Every field the ask is composed from is here, +// so the ask is composed again from it alone to hold the warrant to. +type Proposal struct { + ID string `json:"id"` + Action string `json:"action"` + Level Level `json:"level"` + Key string `json:"key"` + // Value is the value proposed, whole, and Digest its digest (valueDigest); a clear carries none. + Value json.RawMessage `json:"value,omitempty"` + Digest string `json:"digest"` + // Was is the value stored at the level when it was proposed, HadValue whether there was one, and WasDigest its + // digest: what the operator is shown the change against, and what must still stand on Approve. + Was json.RawMessage `json:"was,omitempty"` + HadValue bool `json:"had-value"` + WasDigest string `json:"was-digest"` + Trusted bool `json:"trusted,omitempty"` + // Reaches is each module on each node whose value it moves, as it was judged when proposed. + Reaches []string `json:"reaches"` + // From is who proposed it, as the bus proved the caller; Why is theirs; At is when. + From string `json:"from"` + Why string `json:"why"` + At time.Time `json:"at"` + + // State is where it stands, Outcome what came of it in words, Decided when; Answered is the warrant a person's + // choice held on (Approve or Decline), and Approved who approved it. + State string `json:"state"` + Outcome string `json:"outcome,omitempty"` + Decided time.Time `json:"decided,omitempty"` + Answered string `json:"answered,omitempty"` + Approved string `json:"approved,omitempty"` + // Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not. + Recorded bool `json:"recorded,omitempty"` + Record string `json:"record,omitempty"` +} + +// approval is what a decided proposal gives the set it makes: the warrant in words, and what the operator was +// shown the change against. +type approval struct { + Proposal string + By string + Says string + Was json.RawMessage + HadValue bool + WasDigest string +} + +// valueDigest is the digest a proposal binds of a value: SHA-256 over its one encoding (compact), "none" when +// there is no value. +func valueDigest(raw json.RawMessage, had bool) string { + text := "none" + if had { + text = "value:" + string(compact(raw)) + } + sum := sha256.Sum256([]byte(text)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func shownOrNone(raw json.RawMessage, had bool) string { + if !had { + return "no value set" + } + return string(compact(raw)) +} + +// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four. +func fingerprint(digest string) string { + h := strings.TrimPrefix(digest, "sha256:") + if len(h) < 24 { + return h + } + var g []string + for i := 0; i < 24; i += 4 { + g = append(g, h[i:i+4]) + } + return strings.Join(g, " ") +} + +// Fingerprint is the proposal as the operator is shown it: the value proposed, or for a clear the value it clears. +func (p Proposal) Fingerprint() string { + if p.Action == "clear" { + return fingerprint(p.WasDigest) + } + return fingerprint(p.Digest) +} + +// about is what its ask is about: the value's place, so a newer proposal for it replaces an older one still open +// (ADR 0277 §10, at a level, a key). +func (p Proposal) about() string { return "config." + p.Level.ValueKey(p.Key) } + +// Act is what an answer binds: this proposal's level, key, the digest of its exact value and of the value it was +// shown against, who proposed it and when, and the answer. +func (p Proposal) Act(option string) asks.Act { + return asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action, + "level": p.Level.Kind, "node": p.Level.Node, "module": p.Level.Module, "key": p.Key, "value": p.Digest, + "was": p.WasDigest, "had-value": strconv.FormatBool(p.HadValue), "from": p.From, + "at": p.At.UTC().Format(time.RFC3339Nano)} +} + +// ---- what the operator reads -------------------------------------------------------------------------- + +// Markers for what a phone is not shown. +const ( + markAddress = "‹address›" + markPath = "‹path›" + markWithheld = "‹withheld›" +) + +// sayable is a text as a channel that proves nothing is shown it, under the content rule (outward.Check): as it +// is, or the marker of what it carries. +func sayable(text string) (string, bool) { + refusal, ok := outward.Check(text) + if ok { + return text, true + } + switch refusal.Class { + case "address": + return markAddress, false + case "path": + return markPath, false + } + return markWithheld, false +} + +// change is the change in one line, "+ key: value", "~ key: value (was: old)" or "- key (was: old)": each value +// exact (for a channel that proves who answers, ADR 0277 §6 and issue 383) or under the content rule. +func (p Proposal) change(exact bool) string { + say := func(raw json.RawMessage) string { + text := string(compact(raw)) + if exact { + return text + } + s, _ := sayable(text) + return s + } + switch { + case p.Action == "clear": + return fmt.Sprintf("- %s (was: %s)", p.Key, say(p.Was)) + case p.HadValue: + return fmt.Sprintf("~ %s: %s (was: %s)", p.Key, say(p.Value), say(p.Was)) + } + return fmt.Sprintf("+ %s: %s", p.Key, say(p.Value)) +} + +// whole is whether every value is shown exactly under the content rule. +func (p Proposal) whole() bool { return p.change(false) == p.change(true) } + +// reaches is every node it reaches, in words. +func (p Proposal) reaches() string { + nodes := map[string]bool{} + for _, r := range p.Reaches { + if i := strings.LastIndex(r, " on "); i > 0 { + nodes[r[i+4:]] = true + } + } + switch len(nodes) { + case 0: + return "none today, as it moves no module's value" + case 1: + return sortedKeys(nodes)[0] + " (" + strings.Join(p.Reaches, ", ") + ")" + } + list := sortedKeys(nodes) + return fmt.Sprintf("%d nodes, %s", len(list), strings.Join(list, ", ")) +} + +// summary is the ask's first line: the level, the key and the value, and that it takes effect at once. +func (p Proposal) summary() string { + verb := "Sets " + p.Key + if p.Action == "clear" { + verb = "Clears " + p.Key + } + trusted := "" + if p.Trusted { + trusted = ", a trusted value," + } + reach := p.reaches() + s := fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches: %s.", verb, trusted, + p.Level, reach) + if len([]rune(s)) > asks.SummaryLength { + s = fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches (%d modules).", verb, + trusted, p.Level, len(p.Reaches)) + } + return s +} + +// point is a line cut to what a phone shows at a glance, saying so. +func point(line string) string { + const cut = "… (cut: read it in the note)" + if r := []rune(line); len(r) > asks.PointLength { + return string(r[:asks.PointLength-len([]rune(cut))]) + cut + } + return line +} + +// headline is the ask's headline, shortened when the names make it longer than the router allows (ADR 0277 §5). +func (p Proposal) headline() string { + verb := "Set" + if p.Action == "clear" { + verb = "Clear" + } + where := "for the whole mesh" + switch p.Level.Kind { + case LevelNode: + where = "on " + p.Level.Node + case LevelModule: + where = "for " + p.Level.Module + " on " + p.Level.Node + } + for _, h := range []string{fmt.Sprintf("%s %s %s?", verb, p.Key, where), fmt.Sprintf("%s a config value %s?", verb, + where), verb + " a config value?"} { + if len([]rune(h)) <= asks.HeadlineLength { + if s, ok := sayable(h); ok { + return s + } + } + } + return verb + " a config value?" +} + +// Ask is the proposal's ask, composed from it alone: its words, its two options each binding its act, who +// answers, its expiry and what it is about. +func (p Proposal) Ask() asks.Ask { + why, _ := sayable("Why: " + p.Why) + if why != "Why: "+p.Why { + why = "Why: given, and read in full with the proposal" + } + compose := func(exact bool) string { + return strings.Join([]string{p.summary(), point(p.change(exact)), point(why)}, "\n") + } + details := func(exact bool) string { + var d strings.Builder + d.WriteString(p.change(exact) + "\n") + from, _ := sayable(p.From) + fmt.Fprintf(&d, "Proposed by %s at %s UTC. Fingerprint %s.\n", from, p.At.UTC().Format("15:04 on 2 Jan"), + p.Fingerprint()) + if !exact && !p.whole() { + d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n") + } + fmt.Fprintf(&d, "Read it whole, with this fingerprint: mesh-config's config verb with action proposals and "+ + "proposal %s, through the mesh MCP server.", p.ID) + return d.String() + } + a := asks.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: asks.Operator, + Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(), + Details: details(false)} + if w := compose(true); w != a.Explanation { + a.Whole = w + } + if w := details(true); w != a.Details { + a.WholeDetails = w + } + does := "the value is set at once, and every node it reaches takes it" + if p.Action == "clear" { + does = "the value is cleared at once, and every node it reaches takes what is below it" + } + for _, o := range []struct{ id, label, does string }{ + {OptionApprove, "Approve", does}, + {OptionDecline, "Decline", "nothing changes; the proposal is discarded"}, + } { + binds, _ := asks.ActDigest(p.Act(o.id)) + a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, + Binds: binds}) + } + return a +} + +// problem says why a proposal as kept is not one this module made, or "": judged again before an Approve is +// acted on, so a record whose value or shown value changed after it was asked is refused (ADR 0277 §12). +func (p Proposal) problem() string { + switch { + case !asks.UsableID(p.ID): + return "its id is not one" + case p.Action != "set" && p.Action != "clear": + return "its action is neither set nor clear" + case !keyName.MatchString(p.Key): + return "its key is not one" + case p.At.IsZero(): + return "it says no time" + case p.Action == "set" && valueDigest(p.Value, true) != p.Digest: + return "its value is not the value its digest names: it changed after it was asked" + case p.Action == "clear" && (len(p.Value) != 0 || p.Digest != valueDigest(nil, false)): + return "a clear carries no value" + case valueDigest(p.Was, p.HadValue) != p.WasDigest: + return "the value it was shown against is not the one its digest names: it changed after it was asked" + } + return "" +} + +// ---- proposing ----------------------------------------------------------------------------------------- + +// ProposeAnswer is what a proposal answers. +type ProposeAnswer struct { + Said string `json:"said"` + Proposal string `json:"proposal"` + Asked string `json:"asked"` + Fingerprint string `json:"fingerprint"` + Expires time.Time `json:"expires"` + Router string `json:"router"` + Reaches []string `json:"reaches"` + Replaced []string `json:"replaced,omitempty"` +} + +// AtTheTerminalInstead names the other way, said whenever a proposal cannot be asked (ADR 0277 §19). +const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead, through the controller's " + + "config line, which carries the value to mesh-config" + +// approverReady says why no channel that proves who answered can carry an approval, when this module can tell +// from the channel bench's kinds; an empty list says nothing, and the router is the judge (ADR 0259 §4). +func approverReady(bus AskBus) string { + kinds, err := bus.Kinds() + if err != nil || len(kinds) == 0 { + return "" + } + channels := 0 + for _, k := range kinds { + if k.Seat != asks.ChannelSeat { + continue + } + channels++ + if k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") { + return "" + } + } + if channels == 0 { + return "" + } + return "no channel that proves who answered (one promising a verified sender, such as Telegram) is on the mesh" +} + +func newProposalID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return "cfg-" + hex.EncodeToString(b[:]) +} + +// endedWord is the router's word that an ask ended without a choice, heard while its proposer waits. +type endedWord struct { + Outcome asks.Outcome + Words string +} + +func (c *Config) heardEnded(id string) (endedWord, bool) { + c.endedMu.Lock() + defer c.endedMu.Unlock() + w, ok := c.ended[id] + return w, ok +} + +func (c *Config) tellEnded(id string, w endedWord) { + c.endedMu.Lock() + defer c.endedMu.Unlock() + if c.ended == nil { + c.ended = map[string]endedWord{} + } + c.ended[id] = w +} + +// Propose judges a value as a set would, keeps it, and asks the operator under mesh-config's own name. Nothing is +// set here: only the operator's Approve sets it (Decide). +func (c *Config) Propose(call Call) (*ProposeAnswer, error) { + refuse := func(format string, a ...any) (*ProposeAnswer, error) { + return nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed") + } + if c.Asks == nil { + return refuse("the operator cannot be asked from here; %s", AtTheTerminalInstead) + } + if call.By != "" { + return refuse("by names who gave a value at the controller's terminal; a proposal is asked of the operator") + } + if call.Proposal != "" { + return refuse("proposal names a proposal to read, with action proposals") + } + action := "set" + if call.Clear { + action = "clear" + if call.Value != nil { + return refuse("a clear carries no value") + } + } + c.mu.Lock() + prop, ask, replaced, err := c.propose(call, action) + c.mu.Unlock() + if err != nil { + return nil, err + } + answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(), + Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced, + Router: "the router has not said yet whether it took the ask; config's proposals shows it"} + // The router's word, before answering (ADR 0277 §20): it refuses at once an ask no channel can carry. + within, every := c.RouterWithin, c.RouterEvery + if within <= 0 { + within = RouterAnswersWithin + } + if every <= 0 { + every = RouterAnswersEvery + } + for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(every) { + if w, ended := c.heardEnded(prop.ID); ended { + return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). The proposal is ended and "+ + "nothing is set; %s", w.Outcome, w.Words, AtTheTerminalInstead) + } + if e, err := c.Asks.Record(prop.ID); err == nil && e != nil { + answer.Router = "the router took the ask and shows it on the channels that can carry it" + break + } + } + answer.Said = fmt.Sprintf("proposed %s of %s at %s (fingerprint %s): the operator is asked %q on a channel that "+ + "proves who answered. Nothing changes until they approve; on Approve it takes effect at once, and on Decline "+ + "or at %s UTC it is discarded", action, prop.Key, prop.Level, prop.Fingerprint(), ask.Headline, + ask.Expires.UTC().Format("15:04 on 2 Jan")) + return answer, nil +} + +// propose is Propose's judgement, its record and its ask; the caller holds the lock. +func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string, error) { + fail := func(format string, a ...any) (Proposal, asks.Ask, []string, error) { + return Proposal{}, asks.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed") + } + level, err := call.level() + if err != nil { + return fail("%v", err) + } + if !keyName.MatchString(call.Key) { + return fail("name the key to propose: lower-case letters, digits, dots, dashes and underscores") + } + why := strings.TrimSpace(call.Why) + if why == "" || strings.Contains(why, "\n") { + return fail("say why, in one line: every change keeps its why (novox/hq ADR 0348 §9)") + } + if call.Caller == "" || call.Caller == Unproven { + return fail("the call names no proven caller, and a proposal keeps who made it: call config through the mesh, " + + "which names you") + } + s, err := c.sources() + if err != nil { + return Proposal{}, asks.Ask{}, nil, err + } + // **Judged as a set judges it, before anyone is asked** (ADR 0277 §4): no ask for a value the verb would refuse. + r, trusted, err := c.declared(s, level, call.Key) + if err != nil { + return fail("%v", err) + } + vk := level.ValueKey(call.Key) + was, had := s.Values[vk] + now := c.now() + prop := Proposal{ID: newProposalID(), Action: action, Level: level, Key: call.Key, HadValue: had, + WasDigest: valueDigest(was.Value, had), Trusted: trusted, From: call.Caller, Why: why, At: now, + State: ProposalWaiting, Digest: valueDigest(nil, false)} + if had { + prop.Was = compact(was.Value) + } + after := Sources{Assignments: s.Assignments, Declared: s.Declared, Predefined: s.Predefined, + Values: map[string]Value{}} + for k, v := range s.Values { + after.Values[k] = v + } + switch action { + case "set": + if call.Value == nil { + return fail("give the value to propose for %s", call.Key) + } + raw, err := json.Marshal(call.Value) + if err != nil { + return fail("the value cannot be read: %v", err) + } + var value any + _ = json.Unmarshal(raw, &value) + if problems := r.check(value); len(problems) > 0 { + return fail("%s is refused for %s at %s: %s", show(value), call.Key, level, strings.Join(problems, "; ")) + } + prop.Value, prop.Digest = compact(raw), valueDigest(raw, true) + if had && string(compact(was.Value)) == string(prop.Value) { + return fail("%s at %s already holds exactly %s", call.Key, level, prop.Value) + } + after.Values[vk] = Value{Value: prop.Value} + case "clear": + if !had { + return fail("nothing is set for %s at %s, so there is nothing to clear", call.Key, level) + } + delete(after.Values, vk) + } + for _, m := range diff(Resolve(s), Resolve(after)) { + prop.Reaches = append(prop.Reaches, m.Module+" on "+m.Node) + } + if prop.Reaches == nil { + prop.Reaches = []string{} + } + // **The operator is shown exactly what they approve** where who answered is proven (ADR 0277 §6, §19): a value + // shaped like a secret could be shown nowhere, so it is not proposed; config holds no secret. + if refusal, ok := outward.CheckSecret(prop.change(true)); !ok { + return fail("the change carries %s, which leaves the mesh nowhere, so the operator could not be shown what "+ + "they approve; config holds no secret, and %s", refusal, AtTheTerminalInstead) + } + // **Its own cap of open asks, and the same change once** (ADR 0277 §21); a newer proposal for the same place + // replaces an older one still open (§10). + all, err := c.proposals() + if err != nil { + return Proposal{}, asks.Ask{}, nil, err + } + var replace []Proposal + open := 0 + for _, o := range all { + if o.State != ProposalWaiting || now.After(o.At.Add(ProposalLasts)) { + continue + } + if o.about() == prop.about() { + if o.Action == prop.Action && o.Digest == prop.Digest && o.WasDigest == prop.WasDigest { + return fail("the same change is proposed already, as %s, and waits for the operator's answer", o.ID) + } + replace = append(replace, o) + continue + } + open++ + } + if open >= asks.MostOpen { + return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+ + "config's proposals lists them", open, asks.MostOpen) + } + if why := approverReady(c.Asks); why != "" { + return fail("%s; %s", why, AtTheTerminalInstead) + } + ask := prop.Ask() + if err := ask.Check(now); err != nil { + return fail("%v", err) + } + if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil { + return fail("the proposal could not be kept, so the operator was not asked: %v", err) + } + var replaced []string + for _, o := range replace { + o.State, o.Outcome, o.Decided = ProposalEnded, "replaced by the newer proposal "+prop.ID+"; nothing was set", now + if err := c.Store.Put(ProposalsState, o.ID, o); err == nil { + replaced = append(replaced, o.ID) + } + } + if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { + prop.State, prop.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error() + _ = c.Store.Put(ProposalsState, prop.ID, prop) + c.logf("[mesh-config] proposal %s could not be asked: %v", prop.ID, err) + return Proposal{}, asks.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+ + "and will never be set; %s", err, AtTheTerminalInstead) + } + c.logf("[mesh-config] proposal %s (%s %s at %s, fingerprint %s) asked of the operator", prop.ID, action, prop.Key, + prop.Level, prop.Fingerprint()) + return prop, ask, replaced, nil +} + +// proposals is every proposal kept, oldest first. +func (c *Config) proposals() ([]Proposal, error) { + var out []Proposal + err := c.each(ProposalsState, func(key string, raw []byte) error { + var p Proposal + if json.Unmarshal(raw, &p) != nil || p.ID != key { + return nil // not one this module wrote: never asked, never acted on + } + out = append(out, p) + return nil + }) + if err != nil { + return nil, fmt.Errorf("the proposals cannot be read: %w", err) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) + return out, nil +} + +// ---- deciding -------------------------------------------------------------------------------------------- + +// routerRecord is the router's record of one ask, as far as this module reads it. +type routerRecord struct { + State string `json:"state"` + Ask *asks.Ask `json:"ask"` + Warrant *asks.Warrant `json:"warrant"` +} + +// Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the +// warrant as described at the top of this file; and asks the controller to record every warrant acted on that its +// hand-act log does not hold yet. It answers a line for each decision. +func (c *Config) Decide() ([]string, error) { + if c.Asks == nil { + return nil, nil + } + c.mu.Lock() + defer c.mu.Unlock() + all, err := c.proposals() + if err != nil { + return nil, err + } + now := c.now() + type closed struct { + p Proposal + w asks.Warrant + } + var ready []closed + var said []string + for _, p := range all { + if p.State != ProposalWaiting { + continue + } + e, err := c.Asks.Record(p.ID) + if err == nil && e == nil && now.After(p.At.Add(ProposalLasts+time.Hour)) { + // Ended, and the router keeps no record of it: no answer can be proven, so nothing is set. + said = append(said, c.end(p, "the router keeps no record of its ask (refused before it was taken, or "+ + "forgotten); nothing was set")) + continue + } + if err != nil || e == nil { + continue // open, not yet recorded, or not readable now: read again on the next look + } + var rec routerRecord + if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil { + continue + } + ready = append(ready, closed{p, *rec.Warrant}) + } + sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) }) + for _, r := range ready { + said = append(said, c.decide(r.p, r.w)) + } + c.recordWarrants(now) + return said, nil +} + +// end records a proposal ended without the operator's choice. +func (c *Config) end(p Proposal, why string) string { + p.State, p.Outcome, p.Decided = ProposalEnded, why, c.now() + if err := c.Store.Put(ProposalsState, p.ID, p); err != nil { + return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, why, err) + } + return "proposal " + p.ID + ": " + why +} + +// decide holds one closed ask's warrant to its proposal and, on an Approve that holds, sets the value. The caller +// holds the lock. +func (c *Config) decide(p Proposal, w asks.Warrant) string { + now := c.now() + keep := func(state, outcome string) string { + p.State, p.Outcome, p.Decided = state, outcome, now + if err := c.Store.Put(ProposalsState, p.ID, p); err != nil { + return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, outcome, err) + } + return "proposal " + p.ID + ": " + outcome + } + refused := func(why string) string { + c.logf("[mesh-config] REFUSED the answer to proposal %s: %s", p.ID, why) + return keep(ProposalRefused, "the answer is refused and nothing is set: "+why) + } + if w.Asker != Module || w.Ask != p.ID { + return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask)) + } + if w.Outcome != asks.OutcomeChosen { + why := "the ask " + string(w.Outcome) + if w.Words != "" { + why += " (" + w.Words + ")" + } + return keep(ProposalEnded, why+"; nothing was set") + } + // **The record is held to what was asked** (ADR 0277 §11, §12): its value and the value it was shown against + // still digest to what the ask bound, the warrant answers the ask composed again from it, and the act about to be + // performed is the one the chosen option bound. + if problem := p.problem(); problem != "" { + return refused("the proposal is not the one asked: " + problem) + } + option, err := w.For(Module, p.Ask()) + if err == nil { + err = option.Performs(p.Act(option.ID)) + } + if err != nil { + return refused(err.Error()) + } + switch option.ID { + case OptionDecline: + p.Answered = w.Says() + return keep(ProposalDeclined, "declined by the operator ("+w.Says()+"); nothing was set") + case OptionApprove: + p.Answered, p.Approved = w.Says(), approvedWords(p, w) + default: + return refused("the option " + option.ID + " is not one a proposal offers") + } + // Claimed before it is acted on: a warrant heard twice, or read again, sets nothing twice. + p.State, p.Outcome, p.Decided = ProposalApproved, "approved by the operator; being set", now + if err := c.Store.Put(ProposalsState, p.ID, p); err != nil { + return fmt.Sprintf("proposal %s: approved by the operator, and not set: the decision could not be kept (%v); "+ + "it is read again on the next look", p.ID, err) + } + call := Call{Action: p.Action, Key: p.Key, Module: p.Level.Module, Node: p.Level.Node, Why: p.Why, Caller: p.From, + approval: &approval{Proposal: p.ID, By: "the operator", Says: p.Approved, Was: p.Was, HadValue: p.HadValue, + WasDigest: p.WasDigest}} + if p.Action == "set" { + var v any + _ = json.Unmarshal(p.Value, &v) + call.Value = v + if v == nil { + call.Value = json.RawMessage("null") + } + } + a, err := c.change(call) + if err != nil { + return keep(ProposalRefused, "approved by the operator, and refused on Approve, so nothing is set: "+err.Error()) + } + return keep(ProposalApproved, "approved by the operator and set: "+a.Said) +} + +// approvedWords is who approved it, as kept beside the value (ADR 0277 §15). +func approvedWords(p Proposal, w asks.Warrant) string { + who := "the operator" + if w.By != nil { + who = "the " + w.By.Who + if w.By.Identity != "" { + who += ", as " + w.By.Kind + " identity " + w.By.Identity + } + via := w.By.Kind + if w.By.Verified != "" { + via += " (" + w.By.Verified + ")" + } + who += ", via " + via + } + return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, w.At.UTC().Format(time.RFC3339), p.ID, p.From) +} + +// recordWarrants asks the controller to record each warrant acted on (Approve or Decline) that its hand-act log +// does not hold yet (ADR 0277 §17): the controller reads it from the router's own record. The caller holds the lock. +func (c *Config) recordWarrants(now time.Time) { + all, err := c.proposals() + if err != nil { + return + } + for _, p := range all { + if p.Recorded || p.Answered == "" || now.Sub(p.Decided) > 7*24*time.Hour { + continue + } + if err := c.Asks.Warranted(p.ID); err != nil { + p.Record = "not recorded in the hand-act log yet: " + err.Error() + } else { + p.Recorded, p.Record = true, "" + } + _ = c.Store.Put(ProposalsState, p.ID, p) + } +} + +// HeardEnd takes the router's word on one of this module's asks. An ask ended without a choice ends its proposal +// at once, and is told to a proposer waiting on it; nothing is set on such a word, so it needs no proof beyond its +// subject. A choice is only ever taken from the router's record (Decide). +func (c *Config) HeardEnd(w asks.Warrant) []string { + if w.Asker != Module || !asks.UsableID(w.Ask) { + return nil + } + if w.Outcome == asks.OutcomeChosen { + said, err := c.Decide() + if err != nil { + said = append(said, "reading the operator's answers: "+err.Error()) + } + return said + } + c.mu.Lock() + defer c.mu.Unlock() + defer c.tellEnded(w.Ask, endedWord{Outcome: w.Outcome, Words: w.Words}) + e, err := c.Store.Get(ProposalsState, w.Ask) + if err != nil || e == nil { + return nil + } + var p Proposal + if json.Unmarshal(e.Value, &p) != nil || p.ID != w.Ask || p.State != ProposalWaiting { + return nil + } + why := "the ask " + string(w.Outcome) + if w.Words != "" { + why += " (" + w.Words + ")" + } + return []string{c.end(p, why+"; nothing was set")} +} + +// ---- the list ---------------------------------------------------------------------------------------------- + +// ProposalRow is one proposal as a listing says it. +type ProposalRow struct { + ID string `json:"id"` + Action string `json:"action"` + Level Level `json:"level"` + Key string `json:"key"` + State string `json:"state"` + Outcome string `json:"outcome,omitempty"` + From string `json:"from"` + At time.Time `json:"at"` + Expires time.Time `json:"expires"` + Fingerprint string `json:"fingerprint"` + Approved string `json:"approved,omitempty"` + // What one proposal read whole says besides. + Value json.RawMessage `json:"value,omitempty"` + Was json.RawMessage `json:"was,omitempty"` + Why string `json:"why,omitempty"` + Reaches []string `json:"reaches,omitempty"` + Router string `json:"router,omitempty"` + Phone string `json:"the phone,omitempty"` + Warning string `json:"warning,omitempty"` +} + +// Proposals is every proposal, newest first; with a proposal's id, that one whole, with whether the router's ask +// is the one composed from it. +func (c *Config) Proposals(call Call) ([]ProposalRow, error) { + all, err := c.proposals() + if err != nil { + return nil, err + } + out := []ProposalRow{} + for i := len(all) - 1; i >= 0; i-- { + p := all[i] + if call.Proposal != "" && p.ID != call.Proposal { + continue + } + if (call.Key != "" && p.Key != call.Key) || (call.Node != "" && p.Level.Node != call.Node) || + (call.Module != "" && p.Level.Module != call.Module) { + continue + } + row := ProposalRow{ID: p.ID, Action: p.Action, Level: p.Level, Key: p.Key, State: p.State, Outcome: p.Outcome, + From: p.From, At: p.At, Expires: p.At.Add(ProposalLasts), Fingerprint: p.Fingerprint(), Approved: p.Approved} + if p.State == ProposalWaiting && c.now().After(row.Expires) { + row.State = "expired, not yet heard" + } + if call.Proposal != "" { + row.Value, row.Was, row.Why, row.Reaches = p.Value, p.Was, p.Why, p.Reaches + if problem := p.problem(); problem != "" { + row.Warning = "this proposal is never set: " + problem + } + if c.Asks != nil { + if e, err := c.Asks.Record(p.ID); err == nil && e != nil { + var rec routerRecord + if json.Unmarshal(e.Value, &rec) == nil { + row.Router = rec.State + switch { + case rec.Ask == nil: + row.Phone = "cannot be compared: the router's record keeps no ask" + case rec.Ask.Digest() == p.Ask().Digest(): + row.Phone = "shows the ask composed from exactly this proposal (fingerprint " + p.Fingerprint() + ")" + default: + row.Phone = "shows ANOTHER ask than this proposal composes: do not approve it" + } + } + } + } + } + out = append(out, row) + } + if call.Proposal != "" && len(out) == 0 { + return nil, errors.New("no proposal " + strconv.Quote(call.Proposal) + " is kept") + } + return out, nil +} diff --git a/modules/mesh-config/cmd/mesh-config/proposals_test.go b/modules/mesh-config/cmd/mesh-config/proposals_test.go new file mode 100644 index 00000000..7ebe7825 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/proposals_test.go @@ -0,0 +1,568 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks +// mesh-config publishes, keeps its own record of each under mesh-config's name, and records the operator's choice +// as the router's warrant. Nothing of mesh-config's is trusted by it: it digests the ask it took. +type standIn struct { + mu sync.Mutex + subjects []string + asks map[string]asks.Ask + records map[string][]byte + kinds []stdio.KindHeld + refuse error + warranted []string + // refuseWith makes the router refuse every ask it is published, as the router does for an ask no channel can + // carry: no record, and its word on the decided subject (heard through hear). + refuseWith string + hear func(asks.Warrant) + // noRecord keeps the router from recording an ask it took (an ask it has not taken yet). + noRecord bool +} + +func newStandIn() *standIn { + return &standIn{asks: map[string]asks.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{ + {Seat: asks.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox", + Capabilities: []string{"choice", "exact-render", "verified-sender"}}}} +} + +func (r *standIn) Publish(subject string, body any, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.refuse != nil { + return r.refuse + } + a, ok := body.(asks.Ask) + if !ok || a.ID != id { + return errors.New("not an ask") + } + r.subjects = append(r.subjects, subject) + r.asks[id] = a + if r.refuseWith != "" { + w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeRefused, Words: r.refuseWith, + At: time.Now()} + go r.hear(w) + return nil + } + if !r.noRecord { + raw, _ := json.Marshal(map[string]any{"state": "open", "ask": a}) + r.records[id] = raw + } + return nil +} + +func (r *standIn) Record(id string) (*stdio.StateEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + raw, ok := r.records[id] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: Module + "." + id, Value: raw}, nil +} + +func (r *standIn) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil } + +func (r *standIn) Warranted(ask string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.warranted = append(r.warranted, ask) + return nil +} + +// choose is the operator choosing an option on a channel that proves who answered: the router's record of the ask +// it took, closed with its warrant. +func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warrant)) asks.Warrant { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.asks[id] + if !ok { + t.Fatalf("the router took no ask %s", id) + } + o, ok := a.Option(option) + if !ok { + t.Fatalf("the ask %s offers no %s", id, option) + } + w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, + Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"}, + Channel: "telegram", Proofs: []string{"P1"}, At: time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC), + AskDigest: a.Digest()} + if tweak != nil { + tweak(&w) + } + raw, _ := json.Marshal(map[string]any{"state": "closed", "ask": a, "warrant": w}) + r.records[id] = raw + return w +} + +// aProposingMesh is aMesh with the stand-in channel, its waits short. +func aProposingMesh(t *testing.T) (*Config, *memStore, *standIn) { + t.Helper() + c, m := aMesh(t) + r := newStandIn() + r.hear = func(w asks.Warrant) { c.HeardEnd(w) } + c.Asks, c.RouterWithin, c.RouterEvery = r, 300*time.Millisecond, 5*time.Millisecond + return c, m, r +} + +func propose(t *testing.T, c *Config, call Call) *ProposeAnswer { + t.Helper() + call.Action = "propose" + if call.Caller == "" { + call.Caller = "g14.node-tools" + } + if call.Why == "" { + call.Why = "the agents need the new rule" + } + a, err := c.Propose(call) + if err != nil { + t.Fatalf("propose %s: %v", call.Key, err) + } + return a +} + +func storedProposal(t *testing.T, m *memStore, id string) Proposal { + t.Helper() + e, _ := m.Get(ProposalsState, id) + if e == nil { + t.Fatalf("no proposal %s is kept", id) + } + var p Proposal + if err := json.Unmarshal(e.Value, &p); err != nil { + t.Fatal(err) + } + return p +} + +func storedValue(t *testing.T, m *memStore, key string) (Value, bool) { + t.Helper() + e, _ := m.Get(ValuesState, key) + if e == nil { + return Value{}, false + } + var v Value + if err := json.Unmarshal(e.Value, &v); err != nil { + t.Fatal(err) + } + return v, true +} + +// **A trusted value proposed end to end** (ADR 0348 §8, How it is checked: "A proposal's test"): mesh-config asks +// under its own name; its ask names the level, the key and the value and says the change takes effect at once; +// each option binds them; nothing is set until the operator's Approve; on Approve the value is set as a set is, +// who approved it is kept beside it, and the warrant is recorded in the hand-act log. +func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(t *testing.T) { + c, m, r := aProposingMesh(t) + m.events, m.handActs = nil, nil + value := map[string]any{"permissions": map[string]any{"deny": []any{"Bash(ssh:*)"}}} + a := propose(t, c, Call{Key: "agent-managed-settings", Value: value}) + + // Nothing is set by a proposal. + if _, set := storedValue(t, m, "mesh.agent-managed-settings"); set || len(m.events) != 0 || len(m.handActs) != 0 { + t.Fatalf("a proposal set something: %d events, %d hand-acts", len(m.events), len(m.handActs)) + } + if !strings.Contains(a.Router, "took the ask") || a.Proposal == "" || len(a.Reaches) != 1 || + a.Reaches[0] != "claude-code on g14" { + t.Errorf("the proposal answered %+v", a) + } + // Asked under mesh-config's own name, on the operator channel. + if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject("mesh-config") { + t.Fatalf("asked on %v", r.subjects) + } + ask := r.asks[a.Proposal] + if err := ask.Check(c.now()); err != nil { + t.Fatalf("the ask: %v", err) + } + if err := asks.Glance(ask.Explanation); err != nil { + t.Errorf("the ask does not read at a glance: %v\n%s", err, ask.Explanation) + } + for _, want := range []string{"agent-managed-settings", "mesh config", "takes effect at once", "claude-code", + "a trusted value"} { + if !strings.Contains(ask.Explanation, want) { + t.Errorf("the ask's explanation does not say %q:\n%s", want, ask.Explanation) + } + } + if !strings.Contains(ask.Headline, "agent-managed-settings") || !strings.Contains(ask.Headline, "whole mesh") { + t.Errorf("the headline is %q", ask.Headline) + } + // The value exact where who answers is proven; under the content rule elsewhere. + exact := `+ agent-managed-settings: {"permissions":{"deny":["Bash(ssh:*)"]}}` + shown := ask.Explanation + if ask.Whole != "" { + shown = ask.Whole + } + if !strings.Contains(shown, exact) || !strings.Contains(ask.Details+ask.WholeDetails, a.Proposal) { + t.Errorf("the ask does not show the value whole:\n%s\n%s", shown, ask.WholeDetails) + } + // Each option binds the level, the key and the value. + p := storedProposal(t, m, a.Proposal) + for _, o := range ask.Options { + if o.Level != asks.Approve { + t.Errorf("the option %s is at %s", o.ID, o.Level) + } + act := p.Act(o.ID) + want, _ := asks.ActDigest(act) + if o.Binds != want || act["level"] != LevelMesh || act["key"] != "agent-managed-settings" || + act["value"] != valueDigest(json.RawMessage(`{"permissions":{"deny":["Bash(ssh:*)"]}}`), true) || + act["was"] != valueDigest(nil, false) { + t.Errorf("the option %s binds %s for %v", o.ID, o.Binds, act) + } + } + // Still nothing while the ask is open. + if said, _ := c.Decide(); len(said) != 0 { + t.Errorf("an open ask decided %v", said) + } + + r.choose(t, a.Proposal, OptionApprove, nil) + said, err := c.Decide() + if err != nil || len(said) != 1 || !strings.Contains(said[0], "approved by the operator and set") { + t.Fatalf("the Approve decided %v (%v)", said, err) + } + v, set := storedValue(t, m, "mesh.agent-managed-settings") + if !set || string(v.Value) != `{"permissions":{"deny":["Bash(ssh:*)"]}}` || v.By != "the operator" || + v.Via != "g14.node-tools" || !strings.Contains(v.Approved, "approved by the operator, as telegram identity 4242, "+ + "via telegram (user id verified)") || !strings.Contains(v.Approved, "ask "+a.Proposal) || + !strings.Contains(v.Approved, "proposed by g14.node-tools") { + t.Fatalf("the value is %+v", v) + } + if res := m.resolved(t, "g14", "claude-code", "managed"); res.Source != SourceMesh || + string(res.Value) != string(v.Value) { + t.Errorf("claude-code's managed settings resolve as %+v", res) + } + if len(m.events) != 1 || m.events[0].Approved != v.Approved || len(m.handActs) != 1 || + !strings.Contains(m.handActs[0], "the operator's Approve of proposal "+a.Proposal) { + t.Errorf("the set raised %+v and recorded %q", m.events, m.handActs) + } + listed, _ := c.List(Call{Key: "agent-managed-settings"}) + if len(listed.Set) != 1 || listed.Set[0].Approved != v.Approved { + t.Errorf("the listing says %+v", listed.Set) + } + if p = storedProposal(t, m, a.Proposal); p.State != ProposalApproved || !p.Recorded || + len(r.warranted) != 1 || r.warranted[0] != a.Proposal { + t.Errorf("the proposal is %+v; warrants recorded %v", p, r.warranted) + } + // A warrant heard again, or read again, sets nothing twice. + if said, _ = c.Decide(); len(said) != 0 || len(m.events) != 1 { + t.Errorf("the warrant read again decided %v", said) + } +} + +// **An Approve against a changed value is refused** (ADR 0348 §8, ADR 0277 §13; design 57 K4's proof): the value +// stored at the level changed after the operator was shown the change against it, so nothing is set. +func TestAnApproveIsRefusedWhenTheStoredValueChangedSince(t *testing.T) { + c, m, r := aProposingMesh(t) + a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + // Set at the terminal meanwhile. + set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0}, Caller: ControllerCaller, By: "jochen"}) + m.events = nil + r.choose(t, a.Proposal, OptionApprove, nil) + said, _ := c.Decide() + if len(said) != 1 || !strings.Contains(said[0], "it changed since, so nothing is set") { + t.Fatalf("the Approve decided %v", said) + } + if v, _ := storedValue(t, m, "mesh.agent-managed-settings"); string(v.Value) != `{"b":2}` || v.Approved != "" { + t.Errorf("the value is %+v", v) + } + if len(m.events) != 0 { + t.Errorf("a refused Approve raised %+v", m.events) + } + if p := storedProposal(t, m, a.Proposal); p.State != ProposalRefused { + t.Errorf("the proposal is %s: %s", p.State, p.Outcome) + } + + // And a clear since: the value it was shown against (a value) is no longer stored. + set(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"}) + a = propose(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"}) + set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"}) + r.choose(t, a.Proposal, OptionApprove, nil) + if said, _ = c.Decide(); len(said) != 1 || !strings.Contains(said[0], "no value set") { + t.Fatalf("the Approve after a clear decided %v", said) + } + if _, isSet := storedValue(t, m, "node.g14.font-size"); isSet { + t.Error("an Approve against a cleared value set it") + } +} + +// **A record that changed after it was asked is refused on Approve** (ADR 0277 §12): its value, or the value it was +// shown against, no longer what the ask bound. +func TestAnApproveIsRefusedWhenTheProposalChangedAfterItWasAsked(t *testing.T) { + for _, tc := range []struct { + name string + tamper func(p *Proposal) + want string + }{ + {"its value", func(p *Proposal) { p.Value = json.RawMessage(`{"evil":true}`) }, "its value is not the value"}, + {"its value and digest", func(p *Proposal) { + p.Value = json.RawMessage(`{"evil":true}`) + p.Digest = valueDigest(p.Value, true) + }, "it was not the ask the person was shown"}, + {"the value it was shown against", func(p *Proposal) { + p.Was, p.HadValue = json.RawMessage(`{"x":1}`), true + }, "the value it was shown against"}, + {"its level", func(p *Proposal) { p.Level = Level{Kind: LevelNode, Node: "g14"} }, "not the ask the person was shown"}, + } { + t.Run(tc.name, func(t *testing.T) { + c, m, r := aProposingMesh(t) + a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + r.choose(t, a.Proposal, OptionApprove, nil) + p := storedProposal(t, m, a.Proposal) + tc.tamper(&p) + _ = m.Put(ProposalsState, p.ID, p) + said, _ := c.Decide() + if len(said) != 1 || !strings.Contains(said[0], tc.want) { + t.Fatalf("decided %v, want %q", said, tc.want) + } + if keys, _ := m.Keys(ValuesState); len(keys) != 0 { + t.Errorf("a changed proposal set %v", keys) + } + }) + } +} + +// **A warrant that is not the answer to the ask is refused** (ADR 0277 §11): another ask's digest, another level, an +// answer after expiry, nobody verified. +func TestAWarrantThatDoesNotAnswerTheAskIsRefused(t *testing.T) { + for _, tc := range []struct { + name string + tweak func(w *asks.Warrant) + }{ + {"another ask's digest", func(w *asks.Warrant) { w.AskDigest = "sha256:00" }}, + {"another level", func(w *asks.Warrant) { w.Level = asks.Acknowledge }}, + {"after its expiry", func(w *asks.Warrant) { w.At = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) }}, + {"an option it did not offer", func(w *asks.Warrant) { w.Option = "force" }}, + {"no person", func(w *asks.Warrant) { w.By = nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + c, m, r := aProposingMesh(t) + a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + r.choose(t, a.Proposal, OptionApprove, tc.tweak) + said, _ := c.Decide() + if len(said) != 1 || !strings.Contains(said[0], "refused") { + t.Fatalf("decided %v", said) + } + if keys, _ := m.Keys(ValuesState); len(keys) != 0 { + t.Errorf("a warrant that does not hold set %v", keys) + } + if len(r.warranted) != 0 { + t.Errorf("a refused warrant was recorded as acted on: %v", r.warranted) + } + }) + } +} + +// **On Decline, at expiry, or on the router's refusal nothing is set** (ADR 0277 §9, §16, §20), and a Decline is a +// warrant the hand-act log holds. +func TestDeclineExpiryAndTheRoutersRefusalSetNothing(t *testing.T) { + c, m, r := aProposingMesh(t) + a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + r.choose(t, a.Proposal, OptionDecline, nil) + if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "declined by the operator") { + t.Fatalf("the Decline decided %v", said) + } + if p := storedProposal(t, m, a.Proposal); p.State != ProposalDeclined || p.Approved != "" || !p.Recorded { + t.Errorf("the declined proposal is %+v", p) + } + + // Ended without a choice: the router's word. + a = propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"}) + c.HeardEnd(asks.Warrant{Ask: a.Proposal, Asker: Module, Outcome: asks.OutcomeExpired, At: time.Now()}) + if p := storedProposal(t, m, a.Proposal); p.State != ProposalEnded || !strings.Contains(p.Outcome, "expired") { + t.Errorf("the expired proposal is %+v", p) + } + + // Ended, and the router never recorded it: discarded once its time is past. + r.noRecord = true + a = propose(t, c, Call{Key: "font-size", Value: 13.0, Node: "novox"}) + if !strings.Contains(a.Router, "has not said yet") { + t.Errorf("an ask the router has not taken answered %q", a.Router) + } + later := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) + c.Now = func() time.Time { return later } + if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "keeps no record") { + t.Errorf("an ask never recorded decided %v", said) + } + if keys, _ := m.Keys(ValuesState); len(keys) != 0 { + t.Errorf("an unanswered proposal set %v", keys) + } + + // Refused by the router at once: said to the proposer. + c2, m2, r2 := aProposingMesh(t) + r2.refuseWith = "no channel can carry an approval" + _, err := c2.Propose(Call{Action: "propose", Key: "font-size", Value: 12.0, Caller: "g14.node-tools", Why: "w"}) + if err == nil || !strings.Contains(err.Error(), "no channel can carry an approval") { + t.Fatalf("the router's refusal answered %v", err) + } + all, _ := c2.proposals() + if len(all) != 1 || all[0].State != ProposalEnded { + t.Errorf("the refused proposal is %+v", all) + } + if keys, _ := m2.Keys(ValuesState); len(keys) != 0 { + t.Errorf("a refused proposal set %v", keys) + } +} + +// **A proposal is judged as a set before anyone is asked, and refused at once when it cannot be asked** (ADR 0277 §4, +// §19, §21): a value the verb would refuse; nothing that would change; a value shaped like a secret; no channel that +// proves who answered; a publish that fails; the same change twice; a fourth open ask. +func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) { + c, m, r := aProposingMesh(t) + set(t, c, Call{Key: "font-size", Value: 12.0}) + for _, tc := range []struct { + name string + call Call + want string + }{ + {"a key not declared", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"}, "it declares:"}, + {"a value failing validation", Call{Key: "font-size", Value: 99.0}, "above the maximum 72"}, + {"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, "no module value for the whole mesh"}, + {"nothing that would change", Call{Key: "font-size", Value: 12.0}, "already holds exactly 12"}, + {"a clear of nothing", Call{Key: "font-size", Clear: true, Node: "g14"}, "nothing to clear"}, + {"a clear with a value", Call{Key: "font-size", Clear: true, Value: 1.0}, "a clear carries no value"}, + {"a secret's shape", Call{Key: "agent-managed-settings", Value: map[string]any{"env": map[string]any{ + "API_TOKEN": "ghp_abcdefghijklmnopqrstuvwxyz0123456789"}}}, "leaves the mesh nowhere"}, + {"by", Call{Key: "font-size", Value: 13.0, By: "jochen"}, "by names who gave"}, + {"no why", Call{Key: "font-size", Value: 13.0, Why: " "}, "say why"}, + {"an unproven caller", Call{Key: "font-size", Value: 13.0, Caller: Unproven}, "no proven caller"}, + } { + t.Run(tc.name, func(t *testing.T) { + call := tc.call + call.Action = "propose" + if call.Caller == "" { + call.Caller = "g14.node-tools" + } + if call.Why == "" { + call.Why = "a test" + } + if _, err := c.Propose(call); err == nil || !strings.Contains(err.Error(), tc.want) || + !strings.Contains(err.Error(), "Nothing was proposed") { + t.Fatalf("refused with %v, want %q", err, tc.want) + } + }) + } + if len(r.subjects) != 0 { + t.Fatalf("a refused proposal was asked: %v", r.subjects) + } + + r.kinds = []stdio.KindHeld{{Seat: asks.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}} + if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil || + !strings.Contains(err.Error(), "no channel that proves who answered") { + t.Errorf("no approving channel: %v", err) + } + r.kinds = newStandIn().kinds + r.refuse = errors.New("the bus is away") + if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil || + !strings.Contains(err.Error(), "could not be asked") { + t.Errorf("a failed publish: %v", err) + } + r.refuse = nil + + first := propose(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"}) + if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 14.0, Node: "g14", Caller: "g", Why: "w"}); err == nil || + !strings.Contains(err.Error(), "proposed already, as "+first.Proposal) { + t.Errorf("the same change twice: %v", err) + } + // A newer proposal for the same place replaces the older (ADR 0277 §10), and is not counted twice. + newer := propose(t, c, Call{Key: "font-size", Value: 15.0, Node: "g14"}) + if len(newer.Replaced) != 1 || newer.Replaced[0] != first.Proposal || + storedProposal(t, m, first.Proposal).State != ProposalEnded { + t.Errorf("the newer proposal replaced %v", newer.Replaced) + } + propose(t, c, Call{Key: "font-size", Value: 15.0, Node: "novox"}) + propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + if _, err := c.Propose(Call{Action: "propose", Key: "width", Value: 400.0, Node: "g14", Module: "dunst", + Caller: "g", Why: "w"}); err == nil || !strings.Contains(err.Error(), "3 proposals already wait") { + t.Errorf("a fourth open ask: %v", err) + } + if keys, _ := m.Keys(ValuesState); len(keys) != 1 { + t.Errorf("proposals set %v", keys) + } +} + +// **A clear is proposed and approved the same way**, and a trusted value set or cleared through the verb is refused, +// naming the proposal as the way (ADR 0277 §2). +func TestAClearIsProposedAndATrustedSetNamesTheProposal(t *testing.T) { + c, m, r := aProposingMesh(t) + _, err := c.Change(Call{Action: "set", Key: "agent-managed-settings", Value: map[string]any{}, Caller: "g14.node-tools", + Why: "w"}) + if err == nil || !strings.Contains(err.Error(), "propose it instead") { + t.Errorf("a trusted set through the verb: %v", err) + } + set(t, c, Call{Key: "managed", Value: map[string]any{"a": 1.0}, Node: "g14", Module: "claude-code", + Caller: ControllerCaller, By: "jochen"}) + a := propose(t, c, Call{Key: "managed", Clear: true, Node: "g14", Module: "claude-code"}) + ask := r.asks[a.Proposal] + if !strings.HasPrefix(ask.Headline, "Clear managed for claude-code on g14") || + !strings.Contains(ask.Explanation, `- managed (was: {"a":1})`) { + t.Errorf("the clear asks %q:\n%s", ask.Headline, ask.Explanation) + } + r.choose(t, a.Proposal, OptionApprove, nil) + if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "is cleared") { + t.Fatalf("the clear decided %v", said) + } + if _, isSet := storedValue(t, m, "module.g14.claude-code.managed"); isSet { + t.Error("an approved clear left the value") + } + h, _ := c.History(Call{Key: "managed"}) + if last := h[len(h)-1]; last.Action != "clear" || !strings.Contains(last.Approved, "approved by the operator") { + t.Errorf("the history keeps %+v", last) + } +} + +// **The proposals are listed, and one is read whole** with whether the router's ask is the one composed from it. +func TestTheProposalsAreListedAndOneIsReadWhole(t *testing.T) { + c, m, _ := aProposingMesh(t) + a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}}) + b := propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"}) + rows, err := c.Do(Call{Action: "proposals"}) + if err != nil { + t.Fatal(err) + } + if l := rows.([]ProposalRow); len(l) != 2 || l[0].ID != b.Proposal || l[1].State != ProposalWaiting || l[0].Value != nil { + t.Errorf("listed %+v", l) + } + one, err := c.Proposals(Call{Proposal: a.Proposal}) + if err != nil || len(one) != 1 || string(one[0].Value) != `{"a":1}` || one[0].Fingerprint != a.Fingerprint || + !strings.Contains(one[0].Phone, "exactly this proposal") { + t.Fatalf("read whole %+v (%v)", one, err) + } + p := storedProposal(t, m, a.Proposal) + p.Value, p.Digest = json.RawMessage(`{"a":2}`), valueDigest(json.RawMessage(`{"a":2}`), true) + _ = m.Put(ProposalsState, p.ID, p) + if one, _ = c.Proposals(Call{Proposal: a.Proposal}); !strings.Contains(one[0].Phone, "ANOTHER ask") { + t.Errorf("a changed proposal reads %q", one[0].Phone) + } + if _, err := c.Proposals(Call{Proposal: "cfg-none"}); err == nil { + t.Error("an unknown proposal was read") + } +} + +// **The verb's new arguments are read strictly.** +func TestTheProposalArgumentsAreReadStrictly(t *testing.T) { + call, err := callOf(map[string]any{"action": "propose", "key": "k", "clear": true, "proposal": " cfg-1 "}) + if err != nil || !call.Clear || call.Proposal != "cfg-1" { + t.Errorf("read %+v (%v)", call, err) + } + if _, err := callOf(map[string]any{"clear": "yes"}); err == nil { + t.Error("a clear that is not a boolean was read") + } + c, _ := aMesh(t) + if _, err := c.Do(Call{Action: "set", Key: "font-size", Value: 12.0, Clear: true, Caller: "g", Why: "w"}); err == nil { + t.Error("a set took clear") + } + if _, err := c.Do(Call{Action: "propose", Key: "font-size", Value: 12.0, Caller: "g", Why: "w"}); err == nil || + !strings.Contains(err.Error(), "cannot be asked from here") { + t.Errorf("a proposal with no channel: %v", err) + } +} diff --git a/modules/mesh-config/cmd/mesh-config/pulled_in_test.go b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go new file mode 100644 index 00000000..e4120e2c --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the +// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan, +// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its +// templates; they go when the value goes. +func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) { + c, m := aMesh(t) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+ + `"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`) + if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 { + t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err) + } + expect(t, m, "ace", "10", SourceDefault) + _ = m.Delete(AssignmentsRead, "ace.dunst") + if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { + t.Errorf("a module no longer pulled in moved %+v", r.Moved) + } +} diff --git a/modules/mesh-config/cmd/mesh-config/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go new file mode 100644 index 00000000..f095f324 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -0,0 +1,229 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning, +// from the default in its manifest, mesh config for the predefined key it falls back to, node config for that key +// on the node, and the module's own value on that node. A value naming no predefined key resolves from its default +// and the module's own value only. + +// The states this module keeps, and those it reads (its manifest's `state` and `reads`). +const ( + ValuesState = "values" + ResolvedState = "resolved" + PredefinedState = "predefined" + ProposalsState = "proposals" + + AssignmentsRead = "mesh-controller.assignments" + DeclaredConfigRead = "mesh-controller.declared-config" + PredefinedRead = "mesh-controller.predefined" + + // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). + HistoryLog = "history" +) + +// The sources a resolved value says it came from. +const ( + SourceDefault = "default" + SourceMesh = "mesh" + SourceNode = "node" + SourceModule = "module" + // SourceUnset is a required value nothing sets: mesh-config answers it unset (ADR 0348 §4). + SourceUnset = "unset" +) + +// The levels a value is set at, and how each is keyed in mesh-config_values (ADR 0348 §9). +const ( + LevelMesh = "mesh" + LevelNode = "node" + LevelModule = "module" +) + +// Level is where a value is set: the mesh, a node, or a module on a node. +type Level struct { + Kind string `json:"level"` + Node string `json:"node,omitempty"` + Module string `json:"module,omitempty"` +} + +// ValueKey is the level's key for a config key in mesh-config_values. +func (l Level) ValueKey(key string) string { + switch l.Kind { + case LevelNode: + return "node." + l.Node + "." + key + case LevelModule: + return "module." + l.Node + "." + l.Module + "." + key + } + return "mesh." + key +} + +func (l Level) String() string { + switch l.Kind { + case LevelNode: + return "node config on " + l.Node + case LevelModule: + return l.Module + "'s own value on " + l.Node + } + return "mesh config" +} + +// parseValueKey is the level and the config key a key of mesh-config_values names. +func parseValueKey(k string) (Level, string, bool) { + parts := strings.SplitN(k, ".", 4) + switch { + case len(parts) >= 2 && parts[0] == LevelMesh: + return Level{Kind: LevelMesh}, strings.Join(parts[1:], "."), true + case len(parts) >= 3 && parts[0] == LevelNode: + return Level{Kind: LevelNode, Node: parts[1]}, strings.Join(parts[2:], "."), true + case len(parts) == 4 && parts[0] == LevelModule: + return Level{Kind: LevelModule, Node: parts[1], Module: parts[2]}, parts[3], true + } + return Level{}, "", false +} + +// Value is one key of mesh-config_values: the value set, who set it and why, and when. +type Value struct { + Value json.RawMessage `json:"value"` + // By is who set it, as proven: the caller, or for a value given at the controller's terminal, who gave it there. + By string `json:"by"` + // Via is the bus account the call came from. + Via string `json:"via"` + Why string `json:"why"` + // Approved is who approved it, for a value set on the operator's warrant (ADR 0348 §8, ADR 0277 §15): told from + // one given at the terminal, which leaves it empty. + Approved string `json:"approved,omitempty"` + At time.Time `json:"at"` +} + +// Resolved is one key of mesh-config_resolved, `..`: the value a module on a node takes for a +// key, and where it came from. Built from the sources alone, so the same sources give the same bytes and a pass +// over unchanged sources writes nothing. +type Resolved struct { + Node string `json:"node"` + Module string `json:"module"` + Key string `json:"key"` + Value json.RawMessage `json:"value,omitempty"` + Source string `json:"source"` + // Predefined is the predefined key the value falls back to, when it names one. + Predefined string `json:"predefined,omitempty"` + // Required says an unset value is the operator's own to set. + Required bool `json:"required,omitempty"` + Trusted bool `json:"trusted,omitempty"` +} + +// ResolvedKey is a resolved value's key in mesh-config_resolved. +func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key } + +// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of +// predefined keys, and every value set. +type Sources struct { + Assignments []Assignment + Declared map[string]ModuleDeclarations + Predefined map[string]Published + Values map[string]Value +} + +// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is. +func Resolve(s Sources) map[string]Resolved { + out := map[string]Resolved{} + for _, a := range s.Assignments { + decl, ok := s.Declared[a.Module] + if !ok { + continue + } + for _, key := range sortedKeys(decl.Config) { + r := resolveOne(s, a.Node, a.Module, key, decl.Config[key]) + out[ResolvedKey(a.Node, a.Module, key)] = r + } + } + return out +} + +func resolveOne(s Sources, node, module, key string, d Declaration) Resolved { + r := Resolved{Node: node, Module: module, Key: key, Source: SourceUnset, Predefined: d.Predefined, + Required: d.Required, Trusted: d.Trusted} + if d.HasDefault() { + r.Value, r.Source = compact(d.Default), SourceDefault + } + if d.Predefined != "" { + if p, ok := s.Predefined[d.Predefined]; ok && p.Trusted { + r.Trusted = true + } + if v, ok := s.Values[Level{Kind: LevelMesh}.ValueKey(d.Predefined)]; ok { + r.Value, r.Source = compact(v.Value), SourceMesh + } + if v, ok := s.Values[Level{Kind: LevelNode, Node: node}.ValueKey(d.Predefined)]; ok { + r.Value, r.Source = compact(v.Value), SourceNode + } + } + if v, ok := s.Values[Level{Kind: LevelModule, Node: node, Module: module}.ValueKey(key)]; ok { + r.Value, r.Source = compact(v.Value), SourceModule + } + return r +} + +// compact is a JSON value encoded the one way: objects' keys sorted, no spaces. +func compact(raw json.RawMessage) json.RawMessage { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return raw + } + out, err := json.Marshal(v) + if err != nil { + return raw + } + return out +} + +// Moved is one resolved value that changed: a module on a node whose value for a key moved, from what to what. +type Moved struct { + Node string `json:"node"` + Module string `json:"module"` + Key string `json:"key"` + From json.RawMessage `json:"from,omitempty"` + To json.RawMessage `json:"to,omitempty"` + Source string `json:"source"` + // Gone says the module no longer takes the key there: unassigned, or no longer declared. + Gone bool `json:"gone,omitempty"` +} + +// diff is what moved between the resolved values held and those wanted, in key order. +func diff(held, want map[string]Resolved) []Moved { + var out []Moved + for _, k := range sortedKeys(want) { + w := want[k] + h, had := held[k] + if had && string(encodeResolved(h)) == string(encodeResolved(w)) { + continue + } + m := Moved{Node: w.Node, Module: w.Module, Key: w.Key, To: w.Value, Source: w.Source} + if had { + m.From = h.Value + } + out = append(out, m) + } + for _, k := range sortedKeys(held) { + if _, ok := want[k]; !ok { + h := held[k] + out = append(out, Moved{Node: h.Node, Module: h.Module, Key: h.Key, From: h.Value, Gone: true}) + } + } + sort.SliceStable(out, func(i, j int) bool { + return ResolvedKey(out[i].Node, out[i].Module, out[i].Key) < ResolvedKey(out[j].Node, out[j].Module, out[j].Key) + }) + return out +} + +func encodeResolved(r Resolved) []byte { + raw, err := json.Marshal(r) + if err != nil { + panic(fmt.Sprintf("a resolved value cannot be encoded: %v", err)) + } + return raw +} diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go new file mode 100644 index 00000000..13cd30f1 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -0,0 +1,252 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Reading the sources, writing the resolved values, and publishing the list of predefined keys. + +// sources reads everything a resolution reads, from the bus as it is now. +func (c *Config) sources() (Sources, error) { + s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{}, + Values: map[string]Value{}} + var problems []string + if err := c.each(AssignmentsRead, func(key string, raw []byte) error { + var a Assignment + if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" { + return fmt.Errorf("the assignment %s is not one: %s", key, raw) + } + s.Assignments = append(s.Assignments, a) + return nil + }); err != nil { + return s, fmt.Errorf("the assignments cannot be read: %w", err) + } + if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error { + m, err := ParseModuleDeclarations(raw) + if err != nil { + // One module's declarations unread leaves that module unresolved, said; the rest resolve. + problems = append(problems, fmt.Sprintf("%s: %v", key, err)) + return nil + } + s.Declared[m.Module] = m + return nil + }); err != nil { + return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) + } + if err := c.each(PredefinedState, func(key string, raw []byte) error { + var p Published + if err := json.Unmarshal(raw, &p); err != nil { + return fmt.Errorf("the published predefined key %s is not one: %w", key, err) + } + s.Predefined[key] = p + return nil + }); err != nil { + return s, fmt.Errorf("the published list of predefined keys cannot be read: %w", err) + } + if err := c.each(ValuesState, func(key string, raw []byte) error { + var v Value + if err := json.Unmarshal(raw, &v); err != nil { + return fmt.Errorf("the value %s is not one: %w", key, err) + } + s.Values[key] = v + return nil + }); err != nil { + return s, fmt.Errorf("the values cannot be read: %w", err) + } + for _, p := range problems { + c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p) + } + return s, nil +} + +// each hands every key of a state to visit. +func (c *Config) each(state string, visit func(key string, raw []byte) error) error { + keys, err := c.Store.Keys(state) + if err != nil { + return err + } + for _, k := range keys { + e, err := c.Store.Get(state, k) + if err != nil { + return err + } + if e == nil { + continue // removed between the listing and the read + } + if err := visit(k, e.Value); err != nil { + return err + } + } + return nil +} + +// held is what mesh-config_resolved holds now. +func (c *Config) held() (map[string]Resolved, error) { + out := map[string]Resolved{} + err := c.each(ResolvedState, func(key string, raw []byte) error { + var r Resolved + if err := json.Unmarshal(raw, &r); err != nil { + // A value nobody can read is written again from the sources. + out[key] = Resolved{Key: key, Source: "unreadable"} + return nil + } + out[key] = r + return nil + }) + return out, err +} + +// writeResolved makes mesh-config_resolved hold what the sources resolve to, writing only what moved, and +// answers what moved. +func (c *Config) writeResolved(s Sources) ([]Moved, error) { + held, err := c.held() + if err != nil { + return nil, fmt.Errorf("the resolved values cannot be read: %w", err) + } + want := Resolve(s) + moved := diff(held, want) + var problems []error + for _, m := range moved { + key := ResolvedKey(m.Node, m.Module, m.Key) + if m.Gone { + if err := c.Store.Delete(ResolvedState, key); err != nil { + problems = append(problems, fmt.Errorf("removing %s: %w", key, err)) + } + continue + } + if err := c.Store.Put(ResolvedState, key, want[key]); err != nil { + problems = append(problems, fmt.Errorf("writing %s: %w", key, err)) + } + } + // A key that is no resolved value at all (written by an older mesh-config, or unreadable) is removed. + for key, h := range held { + if _, ok := want[key]; !ok && h.Source == "unreadable" { + if err := c.Store.Delete(ResolvedState, key); err != nil { + problems = append(problems, fmt.Errorf("removing %s: %w", key, err)) + } + } + } + return moved, errors.Join(problems...) +} + +// Resolution is what one pass of Reconcile did. +type Resolution struct { + Moved []Moved `json:"moved"` + Published string `json:"published,omitempty"` + Withheld string `json:"withheld,omitempty"` +} + +// Reconcile publishes the newest list of predefined keys and brings mesh-config_resolved to what the sources +// resolve to: run at start, and whenever an assignment, a declaration or the list moves. A move it makes raises +// config-changed like a set, with no level, naming what moved. +func (c *Config) Reconcile() (Resolution, error) { + c.mu.Lock() + defer c.mu.Unlock() + var r Resolution + published, err := c.publish() + if err != nil { + c.logf("[mesh-config] the list of predefined keys is not published: %v", err) + } + r.Published, r.Withheld = published, c.withheld + s, err := c.sources() + if err != nil { + return r, err + } + moved, err := c.writeResolved(s) + r.Moved = moved + if len(moved) > 0 { + c.record("resolved", Change{Action: "resolve", Why: "an assignment, a declaration or the list of predefined " + + "keys changed", At: c.now(), Moved: moved}) + } + return r, err +} + +// publish makes mesh-config_predefined hold the list the controller kept, when it is newer than the one published +// (ADR 0348 §7: the newest trunk build wins). **A list dropping a key that still holds a value is withheld**: the +// list published stays, and why is said in every listing and in the log, because a value for a key no longer on +// the list could neither be resolved nor cleared by name (§7: removing a key still holding a value is refused). +// It answers what it published, empty when nothing changed. +func (c *Config) publish() (string, error) { + e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey) + if err != nil { + return "", fmt.Errorf("the controller's list cannot be read: %w", err) + } + if e == nil { + return "", nil // no list kept yet + } + var list PredefinedList + if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil { + return "", fmt.Errorf("the controller's list is not one: %s", e.Value) + } + current := map[string]Published{} + if err := c.each(PredefinedState, func(key string, raw []byte) error { + var p Published + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + current[key] = p + return nil + }); err != nil { + return "", err + } + generation := 0 + for _, p := range current { + generation = max(generation, p.Generation) + } + if len(current) > 0 && generation >= list.Generation { + c.withheld = "" + return "", nil + } + held := map[string][]string{} + if err := c.each(ValuesState, func(key string, _ []byte) error { + level, k, ok := parseValueKey(key) + if ok && level.Kind != LevelModule { + if _, kept := list.Keys[k]; !kept { + held[k] = append(held[k], level.String()) + } + } + return nil + }); err != nil { + return "", err + } + if len(held) > 0 { + var why []string + for _, k := range sortedKeys(held) { + why = append(why, fmt.Sprintf("%s (still set as %s)", k, strings.Join(held[k], ", "))) + } + c.withheld = fmt.Sprintf("the list at %s (generation %d) is not published: it drops %s; clear those values "+ + "first, or keep the key (novox/hq ADR 0348 §7)", short(list.Commit), list.Generation, strings.Join(why, "; ")) + c.logf("[mesh-config] %s", c.withheld) + return "", nil + } + c.withheld = "" + var problems []error + for _, k := range sortedKeys(list.Keys) { + p := Published{PredefinedKey: list.Keys[k], Generation: list.Generation, Commit: list.Commit} + if err := c.Store.Put(PredefinedState, k, p); err != nil { + problems = append(problems, err) + } + } + for k := range current { + if _, kept := list.Keys[k]; !kept { + if err := c.Store.Delete(PredefinedState, k); err != nil { + problems = append(problems, err) + } + } + } + if err := errors.Join(problems...); err != nil { + return "", err + } + return fmt.Sprintf("the list at %s (generation %d): %s", short(list.Commit), list.Generation, + joinKeys(list.Keys)), nil +} + +func short(commit string) string { + if len(commit) > 8 { + return commit[:8] + } + return commit +} diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod new file mode 100644 index 00000000..42fbeb9d --- /dev/null +++ b/modules/mesh-config/go.mod @@ -0,0 +1,5 @@ +module mesh-config + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum new file mode 100644 index 00000000..7e6c029b --- /dev/null +++ b/modules/mesh-config/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json new file mode 100644 index 00000000..9665b54c --- /dev/null +++ b/modules/mesh-config/module.json @@ -0,0 +1,73 @@ +{ + "module": "mesh-config", + "version": "1", + "runs-as": "mesh-config", + "claims": [ + { + "name": "mesh-config", + "scope": "mesh", + "serves": [ + "config" + ] + } + ], + "emits": [ + "config-changed" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:mesh-controller.hand-act", + "seat:mesh-controller.warranted" + ], + "reads": [ + "mesh-controller.assignments", + "mesh-controller.declared-config", + "mesh-controller.predefined" + ], + "state": [ + "values", + "resolved", + "predefined", + "proposals" + ], + "logs": [ + "history" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-config", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-config", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-config" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-config", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-config", + "binary": "mesh-config", + "loads": [ + "mesh-config" + ] + } + ] + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/answers.go b/modules/mesh-delivery/cmd/mesh-delivery/answers.go index 3323a5c5..d62685cd 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/answers.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/answers.go @@ -144,7 +144,9 @@ func (h *Holder) accounted(w Walk) bool { return false } -// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. +// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. A walk whose +// word only stops that lapsed give (lapsedOn, novox/hq issue 753) is not held: it stays under the orphans' timer, so +// past the grace without its word it is listed by `stalled`, saying why it was not given. func (h *Holder) heldWalk(w Walk) bool { for _, d := range h.deliveries { if d.State.Final() { @@ -292,12 +294,28 @@ func (h *Holder) orphanLines(now time.Time) []StalledLine { continue } w := s.walk + if lapsed, behind := h.lapsedOf(w); len(lapsed) > 0 || len(behind) > 0 { + // Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why. + says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+ + "merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8)) + if why := h.lapsedSaid(w); why != "" { + says += ": " + why + } + out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: w.ID, + For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(), + H2: "none: its word is given at its turn — the operator's past the bound", Says: says}) + continue + } out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: w.ID, For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(), H2: "none: no delivery holds the walk, so nothing gives it its word — the operator's", Says: fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, and no delivery that has not ended "+ "follows it or is answered by it: `mesh-delivery.show` for the deliveries of %s, and `plans go %s --why …` "+ "starts it by hand", w.ID, w.Repository, shortOf(w.Commit, 8), w.Repository, w.ID)}) + out[len(out)-1].Says += h.endedSaid(w) + if hold := h.coverHeld(id); hold != "" { + out[len(out)-1].Says += "; it is held back meanwhile: " + hold + } } return out } @@ -344,6 +362,7 @@ func (h *Holder) forgetWalks(now time.Time) { } case !w.Open() && now.Sub(s.at) > keptWalkFor: delete(h.walks, id) + delete(h.deliverErr, id) } } } @@ -356,6 +375,168 @@ type turnWant struct { whys []string } +// endedSaid names the ended deliveries a waiting walk answers or that follow it, with their states, so a walk no live +// delivery holds says what it carries instead of only that nothing holds it (novox/hq issue 753); empty for none. +func (h *Holder) endedSaid(w Walk) string { + var ended []string + for _, d := range h.deliveries { + if !d.State.Final() { + continue + } + _, answers := walkAnswers(w, d) + if answers || d.Walk != nil && d.Walk.ID == w.ID { + ended = append(ended, fmt.Sprintf("%s (%s)", d.ID, d.State)) + } + } + if len(ended) == 0 { + return "" + } + sort.Strings(ended) + return "; every delivery it carries ended: " + strings.Join(ended, ", ") +} + +// lapsedOn are the deliveries, by id, whose word an open walk waiting for this owner's word takes from a stop that +// lapsed (novox/hq issue 753): each was stopped by its group for a member delivered since, so its own change waits for +// a later walk of its repository that contains its merge (issue 747), and this walk answers that merge — it names it, +// or is made at it — and has not left it out. Such a walk is the one that delivery waits for: it gives it its word, so +// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for +// itself, or by a person, gets nothing from it. Under the lock. +// +// In its group's order (review of issue 753): a member gives its word only once every member ordered before it reached +// the machines, or is itself a stop that lapsed whose merge this same walk answers — the walk then delivers both, in +// the group's order among its tiers, as members sharing one walk do. A member behind one still undelivered gives +// nothing; lapsedBehind names that one. +func (h *Holder) lapsedOn(w Walk) []string { + out, _ := h.lapsedOf(w) + return out +} + +// lapsedOf is lapsedOn, and, by delivery, the member before it in its group that holds back the word of a stop that +// lapsed whose merge the walk answers. Under the lock. +func (h *Holder) lapsedOf(w Walk) ([]string, map[string]string) { + if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits { + return nil, nil + } + var out []string + behind := map[string]string{} + for _, d := range h.deliveries { + if !h.lapsedAnswered(w, d) { + continue + } + if before := h.lapsedBehind(w, d); before != "" { + behind[d.ID] = before + continue + } + out = append(out, d.ID) + } + sort.Strings(out) + return out, behind +} + +// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out. +func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool { + if d.MergedAs == "" || !h.stopLapsed(d) { + return false + } + if _, ok := walkAnswers(w, d); !ok { + return false + } + _, left := w.leftOut(d.Repository, d.MergedAs) + return !left +} + +// lapsedBehind is the member ordered before a delivery in its group that has not reached the machines and that this +// walk does not deliver with it, with its state; empty for none. +func (h *Holder) lapsedBehind(w Walk, d *Delivery) string { + g := h.groups[d.Group] + if d.Group == "" || g == nil { + return "" + } + members := h.membersOf(g) + byID := map[string]*Delivery{} + for _, m := range members { + byID[m.ID] = m + } + for _, m := range h.inOrder(g, members) { + if m.ID == d.ID { + return "" + } + o := byID[m.ID] + if o == nil || memberReached(o) || h.lapsedAnswered(w, o) { + continue + } + return fmt.Sprintf("%s (%s)", o.ID, o.State) + } + return "" +} + +// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet +// delivered before them, what the last tick held it for, or the controller's refusal of the word. Under the lock. +func (h *Holder) lapsedSaid(w Walk) string { + var parts []string + _, behind := h.lapsedOf(w) + ids := make([]string, 0, len(behind)) + for id := range behind { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + parts = append(parts, fmt.Sprintf("%s waits for %s, ordered before it in its group", id, behind[id])) + } + if why := h.waitsWhy[w.ID]; why != "" { + parts = append(parts, "not let go yet: "+why) + } + if err := h.deliverErr[w.ID]; err != "" { + parts = append(parts, "the controller refused its word: "+err) + } + lapsed, _ := h.lapsedOf(w) + for _, id := range lapsed { + if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) { + parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it", + id, d.LetGo.Local().Format("15:04:05"))) + } + } + return strings.Join(parts, "; ") +} + +// lapsedOwed is whether a walk's word is owed by stops that lapsed: given now, or once a member before one is delivered. +func (h *Holder) lapsedOwed(w Walk) bool { + lapsed, behind := h.lapsedOf(w) + return len(lapsed) > 0 || len(behind) > 0 +} + +// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop +// lapsed and whose merge it answers (lapsedOn, novox/hq issue 753). The word is then given as for any walk: once, +// when every live delivery it carries is at its turn too and it carries no stopped change still held back (words). +// Given once per walk: the walk's id is in the delivery's LetGoWhy once its word was given. Under the lock. +func (h *Holder) lapsedTurns() { + for id, s := range h.walks { + for _, did := range h.lapsedOn(s.walk) { + d := h.deliveries[did] + if strings.Contains(d.LetGoWhy, lapsedWord(id)) { + continue + } + if h.turning == nil { + h.turning = map[string]*turnWant{} + } + want := h.turning[id] + if want == nil { + want = &turnWant{} + h.turning[id] = want + } + if !containsString(want.ids, d.ID) { + want.ids = append(want.ids, d.ID) + want.whys = append(want.whys, fmt.Sprintf("%s: %s its merge %s, stopped by its group for %s, whose change "+ + "was delivered since; it waits for a later walk of %s that contains its merge, and this is that "+ + "walk (novox/hq issue 753)", d.ID, lapsedWord(id), shortOf(d.MergedAs, 8), d.stoppedFor(), d.Repository)) + } + } + } +} + +// lapsedWord is how the word a lapsed stop gives a walk names that walk. +func lapsedWord(walk string) string { return "the walk " + walk + " answers" } + // words are the asks of the word of every walk whose turn came for every live delivery it carries: one ask per // walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5). // Under the lock. @@ -388,6 +569,9 @@ func (h *Holder) words() []func() { if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 { h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " + "asked to leave that merge out first" + if hold := h.coverHeld(walk); hold != "" { + h.waitsWhy[walk] = hold + } h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk, strings.Join(stopped, ", ")) continue @@ -403,9 +587,14 @@ func (h *Holder) words() []func() { defer h.mu.Unlock() delete(h.asking, walk) if err != nil { + if h.deliverErr == nil { + h.deliverErr = map[string]string{} + } + h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753) h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err) return } + delete(h.deliverErr, walk) for _, id := range ids { d := h.deliveries[id] if d == nil { @@ -479,9 +668,28 @@ func (h *Holder) WalksWaiting() []StalledLine { switch { case len(held) > 0: out = append(out, held...) + case h.coverHeld(id) != "": + out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited, + Bound: coverAskBound.String(), H2: "none: it is asked to leave the merge out past the bound", + Says: "held back: " + h.coverHeld(id)}) case h.waitsWhy[id] != "": out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited, Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]}) + case !live && h.lapsedOwed(w): + // Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never + // unheld, while the controller has not taken it, with what holds it. + lapsed, _ := h.lapsedOf(w) + says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " + + "delivered since (novox/hq issue 753)" + if len(lapsed) > 0 { + says = "its word is given for " + strings.Join(lapsed, ", ") + ", stopped for a member delivered since, " + + "whose merge it answers (novox/hq issue 753)" + } + if why := h.lapsedSaid(w); why != "" { + says += "; " + why + } + out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited, + Bound: "0s", H2: "none: it goes at its turn", Says: says}) case !live: says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " + "`mesh-delivery.show` for the deliveries of " + w.Repository @@ -492,7 +700,7 @@ func (h *Holder) WalksWaiting() []StalledLine { "), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word" } out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited, - Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says}) + Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)}) } } sort.Slice(out, func(i, j int) bool { diff --git a/modules/mesh-delivery/cmd/mesh-delivery/effects.go b/modules/mesh-delivery/cmd/mesh-delivery/effects.go index e7db65f2..b7a2115f 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/effects.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/effects.go @@ -150,6 +150,9 @@ func statusOf(d *Delivery) (string, string) { case Delivered: return "success", "delivered" case Failed: + if d.coveredStop() { + return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 747) + } return "failure", clip("failed: "+why, 140) case Superseded: return "warning", clip("superseded: "+why, 140) @@ -178,7 +181,7 @@ func ViewBody(d *Delivery, g *Group) string { var b strings.Builder fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339)) if d.coveredStop() { - fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy) + fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid()) } if d.HeldWhy != "" && d.State == Held { fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy) diff --git a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go index a9f0768a..ded86f62 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go @@ -106,6 +106,8 @@ type fakeController struct { moves map[string][]string plans int down bool + // refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753). + refuseDeliver bool // onCheck runs as a check is asked, before it is answered: what the forge held at that moment. onCheck func() // planned is what Plan answers instead of "builds app", when set. @@ -182,6 +184,9 @@ func (c *fakeController) Deliver(walk, why string) error { if c.down { return errors.New("down") } + if c.refuseDeliver { + return errors.New("the controller refuses the word") + } w, known := c.walks[walk] if !known || w.Delivery == nil || w.Delivery.Go != nil { return fmt.Errorf("%s waits for nobody", walk) @@ -396,6 +401,9 @@ type fakeForge struct { // open is what OpenPulls answers; openDown a forge that cannot say. open []OpenPull openDown bool + // merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722). + merges, comments, unlabels []string + refuse error } func (f *fakeForge) OpenPulls() ([]OpenPull, error) { @@ -649,3 +657,25 @@ func aWalk(id, repo, commit string, waits bool) Walk { } return w } + +// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set. +func (f *fakeForge) Merge(owner, repo string, number int, head string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head)) + return f.refuse +} + +func (f *fakeForge) Comment(owner, repo string, number int, body string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body)) + return nil +} + +func (f *fakeForge) Unlabel(owner, repo string, number, label int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label)) + return nil +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery/cmd/mesh-delivery/holder.go index 45dcaae3..8cc6e8f7 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder.go @@ -60,6 +60,8 @@ type Holder struct { waitsWhy map[string]string // asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile. asking map[string]bool + // deliverErr is, by walk, the controller's last refusal of its word, until it is given (review of issue 753). + deliverErr map[string]string // leaving are, by walk, the merges a stop gathered to leave out of it, asked once (novox/hq ADR 0299). leaving map[string][]string // leaveAsked is when a walk was last asked to leave out the stopped merges it carries (leaveStopped). @@ -67,8 +69,17 @@ type Holder struct { // leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and // to stop the walk whole. leaveRefused map[string]string + // coverAsked are the deliveries that failed or were stopped whose cover by a later finished walk waits for the + // forge's word on containment, worked out each pass (novox/hq issue 747): a walk carrying a merge stopped for one of + // them is held back meanwhile, never asked to leave it out before the answer. + coverAsked map[string]bool + // coverAskedSince is, by delivery, when the forge was first asked about its cover and has not answered: past + // coverAskBound the walk is no longer held back for it, and is asked to leave the merge out (review of issue 747). + coverAskedSince map[string]time.Time // tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407). tried owedTries + // marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go). + marks map[string]*marked } // owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers @@ -120,6 +131,7 @@ func (h *Holder) init() { h.askTried = map[string]time.Time{} h.walkAsked = map[string]time.Time{} h.unchecked = map[string]unchecked{} + h.marks = map[string]*marked{} h.walks = map[string]seenWalk{} h.orphans = map[string]time.Time{} h.asking = map[string]bool{} @@ -300,19 +312,25 @@ func (h *Holder) PullUpdated(p PullEvent) { defer h.mu.Unlock() id := IDOf(p.repository(), p.HeadSHA) now := h.Now() + startedOver := "" if d := h.deliveries[id]; d != nil { - // Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its - // check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound - // runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it. - if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA { - d.BaseSHA = p.BaseSHA - h.keep(d) + if startedOver = startedOverFor(d, p.Number); startedOver == "" { + // Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its + // check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound + // runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it. + if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA { + d.BaseSHA = p.BaseSHA + h.keep(d) + } + if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil { + d.AskedAgain = &now + h.keep(d) + } + return } - if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil { - d.AskedAgain = &now - h.keep(d) - } - return + // A delivery stopped only because its pull request closed unmerged does not end its commit's delivery for + // another pull request with the same head (novox/hq issue 783): started over below, exactly as for a first + // announcement, under the same id — never a transition out of Stopped. } replaced := "" for _, old := range h.deliveries { @@ -326,11 +344,7 @@ func (h *Holder) PullUpdated(p PullEvent) { } } } - d := &Delivery{ID: id, Repository: p.repository(), Commit: p.HeadSHA, Number: p.Number, Title: p.Title, - Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths, - PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid, - After: AfterIn(p.Body), Created: now, BaseSHA: p.BaseSHA} - t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)}) + d, t, err := h.freshAnnouncement(p, now, startedOver) if err != nil { h.refuse(err) return @@ -348,6 +362,34 @@ func (h *Holder) PullUpdated(p PullEvent) { } } +// startedOverFor is why a delivery already kept at a head's id is started over for a pull request's number rather +// than taken as announced again: only when it was stopped solely by an earlier, closed duplicate pull request with +// the same head — still Stopped, ClosedUnmerged, never merged, and under another number (novox/hq issue 783). +// Empty for the ordinary 'announced again' path, or for any other stop (a person's, a group's, its own close). +func startedOverFor(d *Delivery, number int) string { + if d != nil && d.State == Stopped && d.ClosedUnmerged && d.MergedAs == "" && d.Number != number { + return fmt.Sprintf("#%d with the same head closed unmerged", d.Number) + } + return "" +} + +// freshAnnouncement makes a delivery for p's head exactly as a first announcement would: its own id, proposed. +// startedOver, when not empty, is folded into the transition's Why, naming the pull request this replaces +// (novox/hq issue 783); the delivery it replaces is swapped out by the caller keeping the new one under the same +// id (keep), never by a transition out of its final state. +func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver string) (*Delivery, Transition, error) { + d := &Delivery{ID: IDOf(p.repository(), p.HeadSHA), Repository: p.repository(), Commit: p.HeadSHA, Number: p.Number, + Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths, + PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid, + After: AfterIn(p.Body), Created: now, BaseSHA: p.BaseSHA} + why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number) + if startedOver != "" { + why = fmt.Sprintf("%s; %s", why, startedOver) + } + t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: why}) + return d, t, err +} + // takePlaceOf puts a member's new head — a rebase, a push — in its group in the place of the head it superseded, // when the group is closed: a member of it already reached the trunk, and a closed group takes no new member. The // new head has its own check, and the group's composed check runs again for the new heads rather than holding the @@ -479,7 +521,29 @@ func (h *Holder) PullMerged(p PullEvent) { } now := h.Now() var ts []Transition - if d == nil { + if over := startedOverFor(d, p.Number); over != "" { + // A delivery stopped only by an earlier, closed duplicate pull request with the same head is not lost when + // its merge is heard without the open announcement either: it is started over for the merging pull request + // the same way an announcement would, before the merge is taken as usual (novox/hq issue 783). Heard this + // way — its own merge, with no open announcement of this pull request — it is made exactly as a first-heard + // merge in every field but its id: FromForge, and its check the forge's word on the head, so a long-past + // merge replayed from the bus's history still falls under the history rule (issue 313) and is not held for + // a person (review round 1). + fresh, t, err := h.freshAnnouncement(p, now, over) + if err != nil { + h.refuse(err) + return + } + fresh.FromForge = true + if s := p.HeadChecks["mesh/merge-gate"]; s != "" { + fresh.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it", + Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])} + } + d, ts = fresh, []Transition{t} + if d.Check != nil { + ts = append(ts, settle(d, Facts{Now: now})...) + } + } else if d == nil { head := p.HeadSHA if head == "" { head = p.MergeCommit @@ -996,6 +1060,12 @@ func (h *Holder) Tick() { for _, ask := range h.leaveStopped() { ask() } + h.mu.Lock() + asks := h.readyAsks(h.Now()) + h.mu.Unlock() + for _, ask := range asks { + ask() + } } // plan works out, under the lock, what to ask; each ask applies its own answer. @@ -1103,6 +1173,48 @@ func (h *Holder) plan() []func() { // A published delivery whose walk ended done before its word is read the same way (novox/hq issue 474): what // that walk did not build, a later delivered merge may have; and when the cover test says none did, past the // grace, it is stopped saying so — never left waiting for a start that cannot come. + // A delivery that failed or was stopped is read for a later finished walk of its repository that delivered its + // change after all: a walk that failed in an outage and was resumed, or the walk of a later merge that contains it + // (novox/hq issue 747). Read each tick, from what the walks and the kept deliveries say, so one stuck before this + // was built clears by itself after a restart. + h.coverAsked = map[string]bool{} + if h.coverAskedSince == nil { + h.coverAskedSince = map[string]time.Time{} + } + pending := map[string]bool{} + for _, d := range h.deliveries { + if d.State != Failed && d.State != Stopped || d.coveredStop() { + continue + } + by, ask := h.walkCover(d) + if len(ask) > 0 && by == "" { + pending[d.ID] = true + since, asked := h.coverAskedSince[d.ID] + if !asked { + since = now + h.coverAskedSince[d.ID] = now + } + // Held back only within the bound: a forge that keeps erroring never holds a walk for good. + h.coverAsked[d.ID] = now.Sub(since) < coverAskBound + } + if by != "" { + d.DeliveredBy = by + h.tellCovered(d, now) + continue + } + for _, in := range ask { + if a := h.askContains(d, in, now); a != nil { + asks = append(asks, a) + } + } + } + + for id := range h.coverAskedSince { + if !pending[id] { + delete(h.coverAskedSince, id) + } + } + for _, d := range h.deliveries { ended := d.endedUnstarted() if d.coveredStop() && !d.CoverTold { @@ -1186,6 +1298,8 @@ func (h *Holder) plan() []func() { } h.turn(d, "its turn: a delivery in no group") } + // A walk that answers the merge of a delivery whose stop lapsed: that delivery's turn (novox/hq issue 753). + h.lapsedTurns() // The word, once per walk, when every live delivery it carries is at its turn. asks = append(asks, h.words()...) @@ -1264,7 +1378,7 @@ func (h *Holder) laterOnItsWay(d *Delivery) string { // stays final, and the bus's transition event is not said again. func (h *Holder) tellCovered(d *Delivery, now time.Time) { d.CoverTold = true - line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid())) + line := oneLine(fmt.Sprintf("%s delivery %s: %s, %s", now.UTC().Format(time.RFC3339), d.ID, d.State, d.coveredStopSaid())) d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now}) if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) { d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now}) @@ -1274,7 +1388,7 @@ func (h *Holder) tellCovered(d *Delivery, now time.Time) { state, desc := statusOf(d) d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now}) } - h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid()) + h.Logf("[mesh-delivery] %s: %s, %s", d.ID, d.State, d.coveredStopSaid()) h.keep(d) } @@ -1285,6 +1399,104 @@ func strandedUncovered(d *Delivery) bool { return d.State == Stopped && d.Stranded != "" && d.CoveredBy == "" } +// walkCover says which later finished walk delivered the change of a delivery that failed or was stopped, or +// nothing, and which walk commits the forge must still be asked about (novox/hq issue 747). A walk covers it when it +// is of its repository and trunk, done, made after its merge, its commit containing its merge — the merge itself, or +// a later one the forge says contains it — and it built every module the delivery's plan moves, each it did not +// build being one the delivery's own walk said unchanged (issue 611). This is the containment "one walk answers every +// merge it contains" uses (issue 362, ADR 0276), read after the fact: a failed walk resumed to done answers its own +// merge, and a later merge's walk answers every merge it contains. A walk that only said a module unchanged for its +// own merges says nothing of this one's, so it covers no module that way. Walks are read from the controller's records +// this owner holds and from every delivery's kept walk, so a walk long ended still counts after a restart. +func (h *Holder) walkCover(d *Delivery) (string, []string) { + if d.State != Failed && d.State != Stopped || d.MergedAs == "" || d.Plan == nil || len(d.Plan.Moved) == 0 { + return "", nil + } + var ask []string + for _, c := range h.doneWalksOf(d.Repository) { + w := c.walk + if !sameBranch(w.Branch, d.Base) || !w.Created.IsZero() && w.Created.Before(d.MergedAt.Add(-time.Minute)) { + continue + } + var missing []string + for _, m := range d.Plan.Moved { + unchanged := false + if d.Walk != nil { + _, unchanged = d.Walk.Unchanged[m] + } + if !containsString(w.Moved, m) && !unchanged { + missing = append(missing, m) + } + } + if len(missing) > 0 { + continue + } + if !sameCommit(c.commit, d.MergedAs) { + yes, known := d.ContainedIn[c.commit] + if !known && !containsString(ask, c.commit) { + ask = append(ask, c.commit) + } + if !yes { + continue + } + } + return fmt.Sprintf("%s (%s at %s into %s, done, contains its merge %s)", w.ID, d.Repository, shortOf(c.commit, 8), + orNone(d.Base), shortOf(d.MergedAs, 8)), nil + } + return "", ask +} + +// doneWalk is a finished walk and the commit of its repository it carried. +type doneWalk struct { + walk *WalkSeen + commit string +} + +// doneWalksOf are the finished walks of a repository this owner knows, oldest first: the controller's records it +// holds, and each delivery's kept walk with the commit of its repository that walk carried for it. +func (h *Holder) doneWalksOf(repository string) []doneWalk { + byID := map[string]doneWalk{} + for id, s := range h.walks { + w := s.walk + if w.Release != nil || w.IsBatch() || w.State != walkDone || !strings.EqualFold(w.Repository, repository) { + continue + } + byID[id] = doneWalk{walk: w.Seen(), commit: w.Commit} + } + for _, o := range h.deliveries { + if o.Walk == nil || o.Walk.State != walkDone || !strings.EqualFold(o.Repository, repository) { + continue + } + if _, known := byID[o.Walk.ID]; known { + continue + } + commit := o.AnsweredBy + if commit == "" && o.MergedAs != "" && sameCommit(o.Walk.Commit, o.MergedAs) { + commit = o.MergedAs + } + if commit == "" { + continue + } + byID[o.Walk.ID] = doneWalk{walk: o.Walk, commit: commit} + } + out := make([]doneWalk, 0, len(byID)) + for _, c := range byID { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].walk.Created.Equal(out[j].walk.Created) { + return out[i].walk.Created.Before(out[j].walk.Created) + } + return out[i].walk.ID < out[j].walk.ID + }) + return out +} + +// stopLapsed is whether a member its group stopped for another is stopped for nothing any more (see stopLapsed). +func (h *Holder) stopLapsed(d *Delivery) bool { + return stopLapsed(d, func(id string) *Delivery { return h.deliveries[id] }) +} + // unwalkedPast is whether a merged delivery has gone without a walk past the bound one opens within, read // again after it: what holds it for a person, unless something already ends it. func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool { @@ -1727,7 +1939,8 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa // A superseded member a later delivered walk carried counts as reached, as in GroupState: a group counts a // covered member as delivered, so the member after it gets its turn (ADR 0270 decision 2, novox/hq // issues 442 and 445). - if d.State.Reached() || d.State == Superseded && d.CoveredBy != "" { + // A stop or failure a later walk delivered since counts as reached too (novox/hq issues 480 and 747). + if memberReached(d) { continue } if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) { @@ -1752,7 +1965,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa } if w.why != "" { for _, later := range ordered[i+1:] { - if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID, + if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID, d.ID, w.why)); a != nil { asks = append(asks, a) } @@ -1779,9 +1992,13 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa continue } return asks + case h.stopLapsed(d): + // Stopped for a member whose change reached the machines since: it stops nothing more. Its own change is + // delivered by a later walk of its repository that contains it, which then covers it (novox/hq issue 747). + continue case d.State == Failed || d.State == Stopped: for _, later := range ordered[i+1:] { - if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil { + if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil { asks = append(asks, a) } } @@ -2019,7 +2236,7 @@ func mayCarry(c, d *Delivery, module string) bool { // controller ends the walk and walks the other merges again without it, and without each later merge of its // repository, which contains it. Every member a pass stops on one walk is gathered first, and the walk is asked // once, by the first member's ask, after the pass: a member is never walked again because its stop came second. -func (h *Holder) stopInGroup(d *Delivery, why string) func() { +func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() { if d == nil || d.State.Final() { return nil } @@ -2028,6 +2245,7 @@ func (h *Holder) stopInGroup(d *Delivery, why string) func() { h.refuse(err) return nil } + d.StoppedFor = cause // the member it was stopped for: once that one is delivered, the stop lapses (novox/hq issue 747) h.moved(d, []Transition{t}) // Its open walk; or, its walk stopped for another merge, the batch or walk that walks its merge again (review // of #221): a merge walked again and then stopped by its group is left out there too. @@ -2109,6 +2327,12 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) { if last := d.Transitions[len(d.Transitions)-1]; last.Event != EvStop || last.By == "" { continue } + // A stop whose change a later walk delivered since, or one its group made for a member delivered since, + // holds nothing back: the group stops a walk only for a member whose change is still undelivered (novox/hq + // issue 747). + if d.coveredStop() || h.stopLapsed(d) { + continue + } // By the merges it names alone: its own commit may be another's merge it no longer answers. if !slices.ContainsFunc(s.walk.Delivery.Merges, func(m WalkMerge) bool { return strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs) @@ -2126,6 +2350,51 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) { return out, why } +// coverAwaited is whether a stopped merge a walk carries, given as `@`, waits for the forge's +// word on whether a later finished walk delivered it or the member its group stopped it for (novox/hq issue 747). +// It says since when the forge was first asked, the earliest of them. Held only within coverAskBound of the first +// ask: past it the forge's silence or error lets the walk be asked to leave the merge out. Under the lock. +func (h *Holder) coverAwaited(merges []string) (time.Time, bool) { + var since time.Time + awaited := false + for _, d := range h.deliveries { + if d.State != Stopped || d.MergedAs == "" || !containsString(merges, d.Repository+"@"+d.MergedAs) { + continue + } + for _, id := range []string{d.ID, d.stoppedFor()} { + if id == "" || !h.coverAsked[id] { + continue + } + if at := h.coverAskedSince[id]; !awaited || at.Before(since) { + since = at + } + awaited = true + } + } + return since, awaited +} + +// coverHeld says why a walk is held back while the forge is asked whether a later finished walk delivered a stopped +// merge it carries (novox/hq issue 747), or nothing when it is not. Said by `waits`, `stalled` and `stalled walks`, so +// a forge that cannot answer is loud. Under the lock. +func (h *Holder) coverHeld(walk string) string { + without, _ := h.carriesStopped(walk) + if len(without) == 0 { + return "" + } + since, awaited := h.coverAwaited(without) + if !awaited { + return "" + } + return fmt.Sprintf("it carries %s, whose delivery was stopped, and the forge is asked since %s whether a later "+ + "finished walk delivered it; it is asked to leave the merge out if no answer comes by %s (novox/hq issue 747)", + strings.Join(without, ", "), since.Local().Format("15:04:05"), since.Add(coverAskBound).Local().Format("15:04:05")) +} + +// coverAskBound is how long a walk carrying a stopped merge is held back while the forge is asked whether a later +// finished walk delivered it (novox/hq issue 747); past it, the walk is asked to leave the merge out. +const coverAskBound = 10 * time.Minute + // lastWhyOf is the reason of a delivery's last transition. func lastWhyOf(d *Delivery) string { if len(d.Transitions) == 0 { @@ -2161,6 +2430,9 @@ func (h *Holder) leaveStopped() []func() { if without, _ := h.carriesStopped(id); len(without) > 0 { said := fmt.Sprintf("its walk %s is not let go: it carries %s, whose delivery was stopped, and is asked to "+ "leave it out (novox/hq ADR 0299)", id, strings.Join(without, ", ")) + if hold := h.coverHeld(id); hold != "" { + said = "its walk " + id + " is held back: " + hold + } if r := h.leaveRefused[id]; r != "" { said += "; the controller refused: " + r } @@ -2184,6 +2456,9 @@ func (h *Holder) leaveStopped() []func() { delete(h.leaveRefused, id) continue } + if _, awaited := h.coverAwaited(without); awaited { + continue // held back, not stopped, until the forge says whether a later walk delivered it (issue 747) + } // Once per record of it: the record that answers an ask is newer. Asked again after five minutes, should // the ask have gone nowhere. if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute { @@ -2305,18 +2580,28 @@ func againWords(a StopAnswer) string { func GroupState(g *Group, members []*Delivery) (string, string) { count := map[State]int{} reached, ended := 0, 0 - var uncovered []string + var uncovered, lapsed []string + byID := map[string]*Delivery{} + for _, d := range members { + byID[d.ID] = d + } for _, d := range members { - count[d.State]++ if d.State.Final() { ended++ } switch { - case d.State.Reached(), d.State == Superseded && d.CoveredBy != "": + case memberReached(d): + // Reached, whatever its final state: a failure or stop a later walk delivered since is no failure of the + // group (novox/hq issue 747). reached++ + continue + case stopLapsed(d, func(id string) *Delivery { return byID[id] }): + lapsed = append(lapsed, d.ID) + continue case d.State == Superseded: uncovered = append(uncovered, d.ID) } + count[d.State]++ } n := len(members) switch { @@ -2326,6 +2611,11 @@ func GroupState(g *Group, members []*Delivery) (string, string) { return "stopped", "a member was stopped" case n > 0 && reached == n: return "delivered", "every member is delivered" + case g.Closed && len(lapsed) > 0 && reached+len(lapsed) == n: + // Stopped for a member delivered since: its own change waits for a later walk of its repository, which then + // covers it (novox/hq issue 747). + return "delivering", fmt.Sprintf("%d of %d delivered; %s, stopped for a member delivered since, waits for a "+ + "later walk of its repository", reached, n, strings.Join(lapsed, ", ")) case g.Closed && n > 0 && ended == n: return "stopped", supersededWhy + strings.Join(uncovered, ", ") case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" || diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder_test.go b/modules/mesh-delivery/cmd/mesh-delivery/holder_test.go index e2ccccae..5a62afbd 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "strings" "testing" "time" @@ -573,3 +574,49 @@ func TestStalledAndCloseWorkFromTheTable(t *testing.T) { t.Fatalf("H2 closed a held delivery: %v", err) } } + +// novox/hq issue 867, ADR 0382 §1 (contract A): each member of a group in `groups` also says its pull +// request number, full head, base branch and whether it is open; `members` keeps its text form. +func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) { + w := newWorld(t) + headA := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + headB := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + pb := pr("novox/b", 2, headB, "feat/z") + pb.Base = "release/1" + w.h.PullUpdated(pr("novox/a", 1, headA, "feat/z")) + w.h.PullUpdated(pb) + lines := w.h.Groups(false) + if len(lines) != 1 || len(lines[0].Details) != 2 || len(lines[0].Members) != 2 { + t.Fatalf("groups: %+v", lines) + } + got := map[string]MemberLine{} + for _, m := range lines[0].Details { + got[m.Repository] = m + } + a, b := got["novox/a"], got["novox/b"] + if a.Number != 1 || a.Head != headA || a.Base != "main" || !a.Open || a.ID == "" { + t.Fatalf("member a: %+v", a) + } + if b.Number != 2 || b.Head != headB || b.Base != "release/1" || !b.Open { + t.Fatalf("member b: %+v", b) + } + if !strings.Contains(lines[0].Members[0], "(") { + t.Fatalf("members lost its text form: %v", lines[0].Members) + } + // Closed unmerged: still listed while the group is, but not open. + w.h.PullClosed(pr("novox/b", 2, headB, "feat/z")) + for _, l := range w.h.Groups(true) { + for _, m := range l.Details { + if m.Repository == "novox/b" && m.Open { + t.Fatalf("a closed pull request is open: %+v", m) + } + } + } + // JSON keys are the contract's. + raw, _ := json.Marshal(a) + for _, k := range []string{`"number":1`, `"head":"` + headA + `"`, `"base":"main"`, `"open":true`} { + if !strings.Contains(string(raw), k) { + t.Fatalf("%s lacks %s", raw, k) + } + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue747_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue747_test.go new file mode 100644 index 00000000..7646ec14 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/issue747_test.go @@ -0,0 +1,258 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// novox/hq issue 747: in the group fix/a-module-moves-only-when-its-program-changes, the controller's member #336 +// (merged as 5d22d7df) failed at 22:32 during an outage of the forge's tools. The walk of the controller's next merge, +// #339 at 60cc9ec3, which contains 5d22d7df, failed with it and was resumed to done by a rebuild. mesh-delivery still +// held #336 failed, so its group stopped every later walk of the catalogue that carried the catalogue member's merge +// 2edef5a0: the catalogue could deliver nothing. A failed or stopped delivery is covered once a later finished walk of +// its repository that contains its merge built its modules, and a group stops a walk only for a member whose change is +// still undelivered. +// +// The shape is the live state of 2026-10-14 23:07, as mesh-delivery.show gave it, read back after a restart: the +// controller holds none of the walks of the night any more, only the walk of the catalogue's next merge. + +const ( + g747 = "fix/a-module-moves-only-when-its-program-changes" + ctl747, cat747 = "novox/mesh-controller", "novox/mesh-catalog" + head336, merge336 = "b34055f580d4e2a4", "5d22d7df86c73452" + head339, merge339 = "e4acab32d6a20ade", "60cc9ec363dfa7c6" + head301, merge301 = "d94764f00168471f", "2edef5a00ae05605" + merge307 = "0f76b6e2aaaaaaaa" +) + +var ( + at747 = time.Date(2026, 10, 14, 20, 21, 6, 0, time.UTC) + id336, id339, id301 = IDOf(ctl747, head336), IDOf(ctl747, head339), IDOf(cat747, head301) + unchangedRouteProxy747 = map[string]string{"route-proxy": "its build source holds none of the changed files"} +) + +// night747 is the stuck state, with the forge saying, or not, that 60cc9ec3 contains 5d22d7df. +func night747(t *testing.T, contained bool) *world { return night747With(t, contained, nil) } + +// night747With is night747 with #339's walk having built only the modules given, saying the others unchanged. +func night747With(t *testing.T, contained bool, built []string) *world { + t.Helper() + return night747Over(t, func(f *fakeForge) { f.contains[merge336+" in "+merge339] = contained }, built) +} + +// night747Over is night747With with the forge set as given: one that is not told the pair cannot say. +func night747Over(t *testing.T, set func(*fakeForge), built []string) *world { + t.Helper() + at := at747 + failedAt := at.Add(11 * time.Minute) + store := newMemStore() + moved339, unchanged339 := []string{"build-agent", "mesh-controller"}, unchangedRouteProxy747 + if built != nil { + moved339, unchanged339 = built, map[string]string{} + for _, m := range []string{"build-agent", "mesh-controller", "route-proxy"} { + if !containsString(built, m) { + unchanged339[m] = "its build source holds none of the files #339 changed" + } + } + } + m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747, + State: Failed, Since: failedAt, Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at, + AnsweredBy: merge336, Check: &Verdict{Gate: "pass"}, + Plan: planMoving(ctl747, "build-agent", "mesh-controller", "route-proxy"), + Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkFailed, Branch: "main", MovedSaid: true, + Note: "mesh-controller failed to build in tier 0", Created: at.Add(7 * time.Minute), Tiers: 2, + Unchanged: unchangedRouteProxy747}, + Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed, + Why: "its walk failed, carrying its own commit"}}} + m339 := &Delivery{ID: id339, Repository: ctl747, Commit: head339, Number: 339, Base: "main", Branch: "fix/726", + State: Failed, Since: failedAt, Created: at.Add(-10 * time.Minute), MergedAs: merge339, MergedAt: at.Add(7 * time.Minute), + AnsweredBy: merge339, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "mesh-controller"), + // Its walk failed with #336's, and a rebuild resumed it to done: the record it keeps says so. + Walk: &WalkSeen{ID: "plan-339", Commit: merge339, State: walkDone, Branch: "main", MovedSaid: true, + Moved: moved339, Unchanged: unchanged339, + Created: at.Add(11 * time.Minute), Tiers: 2, Tier: 2}, + Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed, + Why: "its walk failed, carrying its own commit"}}} + m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747, + State: Stopped, Since: failedAt, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second), + Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"), + Transitions: []Transition{{At: failedAt, From: Ready, To: Stopped, Event: EvStop, By: byOwner, + Why: "its group " + g747 + " stopped: " + id336 + " failed"}}} + for _, d := range []*Delivery{m336, m339, m301} { + if err := store.PutDelivery(d); err != nil { + t.Fatal(err) + } + } + g := &Group{ID: g747, Members: []string{id301, id336}, Closed: true, Order: []string{id336, id301}, Said: "failed", + OrderOf: commitsOf([]*Delivery{m301, m336}), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute), + Verdict: "pass", Summary: "both compose", Commits: commitsOf([]*Delivery{m301, m336})}} + if err := store.PutGroup(g); err != nil { + t.Fatal(err) + } + ctlr := newFakeController() + // The catalogue's next merge: its walk carries 2edef5a0, merged before it and never walked, and waits for its word. + ctlr.put(Walk{ID: "plan-cat", Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute), + State: walkBuilding, Tiers: [][]string{{"gitea"}}, Modules: map[string]*WalkModule{"gitea": {}}, + Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{ + {Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge307}}}}) + ctlr.mergedAt[merge301], ctlr.mergedAt[merge307] = m301.MergedAt, at.Add(46*time.Minute) + forge := newFakeForge() + set(forge) + w := newWorldOver(t, store, ctlr, forge, at.Add(47*time.Minute)) + if err := w.h.Reconcile(); err != nil { + t.Fatal(err) + } + w.settleAll() + return w +} + +func groupState747(w *world) (string, string) { + g := w.h.groups[g747] + return GroupState(g, w.h.membersOf(g)) +} + +// **A failed member is covered by a later finished walk that contains it**: #339's walk, done at 60cc9ec3, which the +// forge says contains 5d22d7df, built build-agent and mesh-controller, and #336's own walk said route-proxy unchanged. +// #336 stays failed, reads covered by that walk on its pull request, and its group no longer reads failed. #339, whose +// own walk was resumed to done, is covered by it too. +func TestAFailedMemberIsCoveredByALaterDoneWalkThatContainsIt(t *testing.T) { + w := night747(t, true) + d := w.delivery(id336) + if state, desc := statusOf(d); d.State != Failed || state != "success" || !strings.Contains(desc, "covered after its failure") || + !strings.Contains(desc, "plan-339") { + t.Fatalf("#336's status reads %s %q", state, desc) + } + if s := statusOn(w, ctl747, head336); !strings.HasPrefix(s, "success") { + t.Fatalf("the forge was told %q of #336", s) + } + if state, desc := statusOf(w.delivery(id339)); state != "success" || !strings.Contains(desc, "plan-339") { + t.Fatalf("#339 reads %s %q; want it covered by its own walk, resumed to done", state, desc) + } + if state, why := groupState747(w); state == "failed" || state == "stopped" { + t.Fatalf("the group reads %s: %s", state, why) + } + // Told once, and kept: a restart tells nothing again. + notes := len(w.forge.notesOn(merge336)) + again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute)) + again.settleAll() + if state, _ := statusOf(again.delivery(id336)); len(w.forge.notesOn(merge336)) != notes || state != "success" { + t.Fatalf("a restart told it again or forgot it: %d notes, was %d", len(w.forge.notesOn(merge336)), notes) + } +} + +// **The group then no longer stops the other repository's walk**: the catalogue member was stopped for #336 alone, +// so the walk of the catalogue's next merge, which carries its merge, is neither stopped nor held back, and the +// group waits for that walk to deliver the catalogue member's change. +func TestACoveredMemberNoLongerStopsTheOtherRepositorysWalk(t *testing.T) { + w := night747(t, true) + if len(w.ctl.withouts) > 0 || containsString(w.ctl.stops, "plan-cat") { + t.Fatalf("the catalogue's walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops) + } + if stopped, _ := w.h.carriesStopped("plan-cat"); len(stopped) > 0 { + t.Fatalf("the catalogue's walk is held back for %v", stopped) + } + if state, why := groupState747(w); state != "delivering" || !strings.Contains(why, id301) { + t.Fatalf("the group reads %s: %s; want delivering, the catalogue member waiting for a later walk", state, why) + } + // Once a later walk of the catalogue that contains its merge built its modules, it is covered, and the group is + // delivered. + w.forge.contains[merge301+" in "+merge307] = true + r := w.ctl.walk("plan-cat") + sent := w.now + r.Modules = map[string]*WalkModule{} + for _, m := range []string{"build-agent", "route-proxy", "gitea"} { + r.Modules[m] = &WalkModule{State: "built", SentAt: &sent} + } + r.State = walkDone + w.h.WalkMoved(w.ctl.put(r)) + w.later(time.Minute) + w.settleAll() + if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, "plan-cat") { + t.Fatalf("the catalogue member reads %s %q", state, desc) + } + if state, why := groupState747(w); state != "delivered" { + t.Fatalf("the group reads %s: %s", state, why) + } +} + +// **A failed member not contained still stops it**: the forge says 60cc9ec3 does not contain 5d22d7df, so #336's change +// never reached the machines; it stays failed and uncovered, its group failed, and the catalogue's walk that carries +// the stopped member's merge is asked to leave it out. +func TestAFailedMemberNotContainedStillStopsTheOtherRepositorysWalk(t *testing.T) { + w := night747(t, false) + d := w.delivery(id336) + if state, desc := statusOf(d); d.State != Failed || state != "failure" { + t.Fatalf("#336 is %s and reads %s %q", d.State, state, desc) + } + if state, _ := groupState747(w); state != "failed" { + t.Fatalf("the group reads %s", state) + } + if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) { + t.Fatalf("the catalogue's walk was not asked to leave the stopped merge out: %v", w.ctl.withouts) + } +} + +// **A later walk that only said a module unchanged covers nothing of an earlier merge that moves it**: #339's walk +// built mesh-controller alone and said build-agent unchanged for #339's own files, which says nothing of #336's; only +// #336's own walk says that of its change (route-proxy). #336 stays failed and its group still stops the catalogue. +func TestALaterWalksUnchangedCoversNoModuleOfAnEarlierMerge(t *testing.T) { + w := night747With(t, true, []string{"mesh-controller"}) + if state, desc := statusOf(w.delivery(id336)); state != "failure" { + t.Fatalf("#336 reads %s %q, though no walk built build-agent for it", state, desc) + } + if state, _ := groupState747(w); state != "failed" { + t.Fatalf("the group reads %s", state) + } + if len(w.ctl.withouts) == 0 { + t.Fatal("the catalogue's walk was not asked to leave the stopped merge out") + } +} + +// **A forge that cannot say holds the walk only within the bound** (review of issue 747): while the forge errors on +// whether 60cc9ec3 contains 5d22d7df, the catalogue's walk is held back, loudly, neither stopped nor asked to leave the +// merge out, and #336 still reads failed. Past the bound the forge's silence holds nothing back any more: the walk +// is asked to leave the stopped merge out, as with no later walk at all. +func TestAForgeThatCannotSayHoldsTheWalkOnlyWithinTheBound(t *testing.T) { + for _, c := range []struct { + name string + set func(*fakeForge) + }{ + {"erroring", func(f *fakeForge) { f.containsDown = true }}, + {"not answering for the pair", func(*fakeForge) {}}, + } { + t.Run(c.name, func(t *testing.T) { + w := night747Over(t, c.set, nil) + if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 { + t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops) + } + if state, desc := statusOf(w.delivery(id336)); state != "failure" { + t.Fatalf("#336 reads %s %q while the forge cannot say", state, desc) + } + said := "" + for _, l := range w.h.WalksWaiting() { + if l.Walk == "plan-cat" { + said += l.Says + } + } + w.later(3 * time.Minute) // past the grace an unheld walk is given, so `stalled` lists it + w.settleAll() + for _, l := range w.h.Stalled() { + if l.Walk == "plan-cat" { + said += " | " + l.Says + } + } + if strings.Count(said, "the forge is asked since") != 2 { + t.Fatalf("the hold is not said by `stalled walks` and `stalled`: %q", said) + } + if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 { + t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops) + } + w.later(coverAskBound + time.Minute) + w.settleAll() + if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) { + t.Fatalf("past the bound the walk was not asked to leave the stopped merge out: %v", w.ctl.withouts) + } + }) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue753_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue753_test.go new file mode 100644 index 00000000..8bb50c99 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/issue753_test.go @@ -0,0 +1,292 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// novox/hq issue 753: once issue 747's fix was live (2026-10-14 23:35), the catalogue's walk plan-1792012015539062661 at +// 0f76b6e2 (#307's merge; build-agent, gitea, route-proxy to all four machines) was resumed and said "published; its +// walk waits for mesh-delivery's word: no delivery that has not ended follows it or is answered by it". Every delivery +// it carried had ended: #301 stopped by its group for the controller's #336, delivered since, and waiting for "a later +// walk of its repository that contains its merge"; #304 stopped; #307 delivered. The only walk that contained #301's +// merge waited for #301's word: a deadlock, released by a hand `plans go`. A delivery whose stop lapsed gives its word +// to the walk that answers its merge, and a walk with no living delivery behind it says what it carries. +// +// The shape is the live state as `mesh-delivery.show` and `mesh-controller.plans` gave it, read back after a restart. + +const ( + merge304, head304, head307 = "4c0ffee0bbbbbbbb", "a304a304a304a304", "b307b307b307b307" + plan753 = "plan-1792012015539062661" +) + +var id304, id307 = IDOf(cat747, head304), IDOf(cat747, head307) + +// night753 is the stuck walk: #336 delivered (its change reached the machines), #301 stopped by the given act, #304 +// stopped by its group, #307 delivered, and the catalogue's walk at #307's merge naming all three merges, waiting. +func night753(t *testing.T, stop Transition) *world { return night753With(t, stop) } + +// night753With is night753 with more deliveries kept: one in #301's group is ordered after #336 and before #301. +func night753With(t *testing.T, stop Transition, more ...*Delivery) *world { + t.Helper() + return night753Over(t, stop, nil, nil, more...) +} + +// night753Over is night753With with more merges named by the walk and the controller set as given before the first +// tick. +func night753Over(t *testing.T, stop Transition, merges []WalkMerge, set func(*fakeController), more ...*Delivery) *world { + t.Helper() + at := at747 + store := newMemStore() + m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747, + State: Delivered, Since: at.Add(70 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at, + AnsweredBy: merge336, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "build-agent", "mesh-controller"), + Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkDone, Branch: "main", MovedSaid: true, + Moved: []string{"build-agent", "mesh-controller"}, Created: at.Add(7 * time.Minute), Tiers: 2, Tier: 2}} + m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747, + State: Stopped, Since: stop.At, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second), + Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"), Transitions: []Transition{stop}} + if stop.By == byOwner { + m301.StoppedFor = id336 + } + m304 := &Delivery{ID: id304, Repository: cat747, Commit: head304, Number: 304, Base: "main", Branch: "fix/forge-events", + State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge304, + MergedAt: at.Add(30 * time.Minute), Check: &Verdict{Gate: "pass"}, + Plan: planMoving(cat747, "build-agent", "gitea", "route-proxy"), + // Stopped with its walk, which the walk of #307 took over, and covered since by the walk of #306 (issue 747). + DeliveredBy: "plan-1792011830935225963 (novox/mesh-catalog at 0361c44a into main, done, contains its merge 4c0ffee0)", + CoverTold: true, + Walk: &WalkSeen{ID: "plan-1792009301770286799", Commit: merge304, State: walkSuperseded, Branch: "main", + TakenOverBy: plan753, Waits: true, WaitedFor: "mesh-delivery", Created: at.Add(31 * time.Minute)}, + Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Published, To: Stopped, Event: EvStop, + Why: "its walk plan-1792009301770286799 was stopped by mesh-delivery"}}} + m307 := &Delivery{ID: id307, Repository: cat747, Commit: head307, Number: 307, Base: "main", Branch: "fix/ts-check", + State: Delivered, Since: at.Add(90 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge307, + MergedAt: at.Add(46 * time.Minute), Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent")} + for _, d := range append([]*Delivery{m336, m301, m304, m307}, more...) { + if err := store.PutDelivery(d); err != nil { + t.Fatal(err) + } + } + inGroup, order := []*Delivery{m301, m336}, []string{id336} + for _, d := range more { + if d.Group == g747 { + inGroup, order = append(inGroup, d), append(order, d.ID) + } + } + order = append(order, id301) + var ids []string + for _, d := range inGroup { + ids = append(ids, d.ID) + } + g := &Group{ID: g747, Members: ids, Closed: true, Order: order, Said: "delivering", + OrderOf: commitsOf(inGroup), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute), + Verdict: "pass", Summary: "all compose", Commits: commitsOf(inGroup)}} + if err := store.PutGroup(g); err != nil { + t.Fatal(err) + } + ctlr := newFakeController() + modules := map[string]*WalkModule{"build-agent": {}, "gitea": {}, "route-proxy": {}} + ctlr.put(Walk{ID: plan753, Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute), + State: walkBuilding, Tiers: [][]string{{"build-agent", "gitea", "route-proxy"}}, Modules: modules, + Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{ + {Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge304, Carried: merge307}, + {Repository: cat747, Commit: merge307}}}}) + if len(merges) > 0 { + r := ctlr.walk(plan753) + r.Delivery.Merges = append(merges, r.Delivery.Merges...) + ctlr.put(r) + } + if set != nil { + set(ctlr) + } + ctlr.mergedAt[merge301], ctlr.mergedAt[merge304], ctlr.mergedAt[merge307] = m301.MergedAt, m304.MergedAt, m307.MergedAt + w := newWorldOver(t, store, ctlr, newFakeForge(), at.Add(100*time.Minute)) + if err := w.h.Reconcile(); err != nil { + t.Fatal(err) + } + w.settleAll() + return w +} + +// groupStop753 is #301's stop as its group made it: for #336, whose change was delivered since. +func groupStop753() Transition { + return Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner, + Why: "its group " + g747 + " stopped: " + id336 + " failed"} +} + +func saidOf(lines []StalledLine, walk string) (string, State) { + said, state := "", State("") + for _, l := range lines { + if l.Walk == walk { + said, state = said+l.Says, l.State + } + } + return said, state +} + +// **A stopped delivery gives its word to the later walk it waits for**: #301, stopped for #336, delivered since, waits for +// a later walk of the catalogue that contains its merge; plan-1792012015539062661 names that merge and waits for this +// owner's word. #301 gives it, once, saying why, and `stalled walks` never calls the walk unheld. +func TestAStoppedDeliveryGivesItsWordToTheWalkItWaitsFor(t *testing.T) { + w := night753(t, groupStop753()) + if n := strings.Count(strings.Join(w.ctl.delivers, " "), plan753); n != 1 { + t.Fatalf("the walk was let go %d times (%v); want once", n, w.ctl.delivers) + } + d := w.delivery(id301) + if d.State != Stopped || !strings.Contains(d.LetGoWhy, lapsedWord(plan753)) || !strings.Contains(d.LetGoWhy, "issue 753") { + t.Fatalf("#301 is %s, its word given for %q", d.State, d.LetGoWhy) + } + if said, state := saidOf(w.h.WalksWaiting(), plan753); state == Unheld { + t.Fatalf("the walk reads unheld: %q", said) + } + if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 { + t.Fatalf("the walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops) + } + // Once that walk built build-agent and route-proxy and ended done, #301 is covered by it (issue 747). + w.forge.contains[merge301+" in "+merge307] = true + r := w.ctl.walk(plan753) + sent := w.now + for _, m := range []string{"build-agent", "gitea", "route-proxy"} { + r.Modules[m] = &WalkModule{State: "built", SentAt: &sent} + } + r.State = walkDone + w.h.WalkMoved(w.ctl.put(r)) + w.later(time.Minute) + w.settleAll() + if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, plan753) { + t.Fatalf("#301 reads %s %q", state, desc) + } +} + +// **A walk still holding a live delivery not at its turn waits for it**: the stopped delivery's word is one turn among +// the walk's, never a way past a live delivery it carries. +func TestALapsedStopDoesNotLetGoPastALiveDelivery(t *testing.T) { + at := at747 + held := &Delivery{ID: IDOf(cat747, "c308c308c308c308"), Repository: cat747, Commit: "c308c308c308c308", Number: 308, + Base: "main", Branch: "fix/held", State: Held, HeldWhy: "a person's", Since: at, Created: at.Add(-time.Hour), + MergedAs: "c308aaaaaaaaaaaa", MergedAt: at.Add(40 * time.Minute), Check: &Verdict{Gate: "pass"}, + Plan: planMoving(cat747, "gitea"), Walk: &WalkSeen{ID: plan753, Commit: merge307, State: walkBuilding, Branch: "main", + Waits: true, Created: at.Add(46 * time.Minute)}} + w := night753With(t, groupStop753(), held) + if containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk was let go past a held delivery it carries: %v", w.ctl.delivers) + } + if said, state := saidOf(w.h.WalksWaiting(), plan753); state != Held { + t.Fatalf("`stalled walks` reads %s %q; want the held delivery", state, said) + } +} + +// **A stop by a person gives no word**: #301 stopped by hand holds its change back; the walk is asked to leave it out, +// never let go for it. +func TestAPersonsStopGivesNoWord(t *testing.T) { + w := night753(t, Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: "jochen", + Why: "stopped by hand"}) + if containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk was let go for a person's stop: %v", w.ctl.delivers) + } + if len(w.ctl.withouts) == 0 && len(w.ctl.stops) == 0 { + t.Fatal("the walk carrying a person's stop was neither left without it nor stopped") + } +} + +// **A walk no living delivery holds says what it carries** (the issue's third expectation): with nothing to give it +// its word, `stalled walks` and, past the grace, `stalled` name every ended delivery it carries and its state, never +// only that nothing holds it. +func TestAWalkWithNoLivingDeliverySaysWhatItCarries(t *testing.T) { + w := night753(t, groupStop753()) + // The walk read again as waiting, naming only #307's merge, which ended delivered: no delivery whose stop lapsed + // gives it its word, and no live one holds it. + r := w.ctl.walk(plan753) + r.Delivery.Go = nil + r.Delivery.Merges = []WalkMerge{{Repository: cat747, Commit: merge307}} + w.h.WalkMoved(w.ctl.put(r)) + said, state := saidOf(w.h.WalksWaiting(), plan753) + if state != Unheld || !strings.Contains(said, id307+" (delivered)") { + t.Fatalf("`stalled walks` reads %s %q; want unheld, naming %s (delivered)", state, said, id307) + } + w.settleAll() + w.later(matchGrace + time.Minute) + w.settleAll() + said, _ = saidOf(w.h.Stalled(), plan753) + if !strings.Contains(said, "every delivery it carries ended: ") || !strings.Contains(said, id307+" (delivered)") { + t.Fatalf("`stalled` reads %q", said) + } +} + +// m299 is a member of #301's group ordered before it, stopped for #336 as #301 was, its merge on another walk. +func m299() *Delivery { + at := at747 + return &Delivery{ID: IDOf(cat747, "e299e299e299e299"), Repository: cat747, Commit: "e299e299e299e299", Number: 299, + Base: "main", Branch: g747, Group: g747, State: Stopped, StoppedFor: id336, Since: at.Add(11 * time.Minute), + Created: at.Add(-3 * time.Hour), MergedAs: "e299aaaaaaaaaaaa", MergedAt: at.Add(5 * time.Second), + Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "gitea"), + Transitions: []Transition{{At: at.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner, + Why: "its group " + g747 + " stopped: " + id336 + " failed"}}} +} + +// **A stopped member gives its word only in its group's order** (review of issue 753): #299, ordered before #301 and +// stopped for #336 too, is not delivered and this walk does not carry its merge, so #301 gives no word — it would land +// before #299 — and `stalled walks` names #299. When the walk names #299's merge too, it delivers both, in order, and +// is let go. +func TestAStoppedMemberGivesItsWordOnlyInItsGroupsOrder(t *testing.T) { + w := night753With(t, groupStop753(), m299()) + if containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk was let go for #301 before #299, ordered before it: %v", w.ctl.delivers) + } + said, state := saidOf(w.h.WalksWaiting(), plan753) + if state == Unheld || !strings.Contains(said, m299().ID+" (stopped)") { + t.Fatalf("`stalled walks` reads %s %q; want #299 named", state, said) + } + w = night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: "e299aaaaaaaaaaaa", Carried: merge307}}, nil, + m299()) + if !containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk carrying both members was not let go: %v", w.ctl.delivers) + } +} + +// **A word the controller refuses is never silent** (review of issue 753): the controller keeps refusing it, and past +// the grace `stalled` lists the walk, with the refusal. +func TestARefusedWordIsListedByStalled(t *testing.T) { + w := night753Over(t, groupStop753(), nil, func(c *fakeController) { c.refuseDeliver = true }) + if containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk was let go: %v", w.ctl.delivers) + } + w.later(matchGrace + time.Minute) + w.settleAll() + said, state := saidOf(w.h.Stalled(), plan753) + if state != Published || !strings.Contains(said, "the controller refused its word: the controller refuses the word") { + t.Fatalf("`stalled` reads %s %q", state, said) + } + // Once the controller takes it, the walk is let go. + w.ctl.refuseDeliver = false + w.settleAll() + if !containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk was not let go once the controller took the word: %v", w.ctl.delivers) + } +} + +// **A walk held back for another stopped change is never silent** (review of issue 753): #304 was stopped by a person, +// and the controller refuses to leave its merge out or to stop the walk, so the word #301 owes is never sent; past the +// grace `stalled` lists the walk, saying why. +func TestAWalkHeldForAPersonsStopIsListedByStalled(t *testing.T) { + at := at747 + m305 := &Delivery{ID: IDOf(cat747, "f305f305f305f305"), Repository: cat747, Commit: "f305f305f305f305", Number: 305, + Base: "main", Branch: "fix/other", State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour), + MergedAs: "f305aaaaaaaaaaaa", MergedAt: at.Add(35 * time.Minute), Check: &Verdict{Gate: "pass"}, + Plan: planMoving(cat747, "gitea"), Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Ready, + To: Stopped, Event: EvStop, By: "jochen", Why: "stopped by hand"}}} + w := night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: m305.MergedAs, Carried: merge307}}, + func(c *fakeController) { c.refuseWithout, c.refuseStop = true, true }, m305) + if containsString(w.ctl.delivers, plan753) { + t.Fatalf("the walk carrying a person's stop was let go: %v", w.ctl.delivers) + } + w.later(matchGrace + time.Minute) + w.settleAll() + said, _ := saidOf(w.h.Stalled(), plan753) + if !strings.Contains(said, "owe it (novox/hq issue 753)") || !strings.Contains(said, "f305aaaaaaaaaaaa") || + !strings.Contains(said, "whose delivery was stopped") { + t.Fatalf("`stalled` reads %q", said) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue783_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue783_test.go new file mode 100644 index 00000000..c0dd6062 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/issue783_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// novox/hq issue 783: mesh-catalog commit b5417c28 was pushed while #305 (from branch adr-359-mesh-mail-733) was +// open, so this owner recorded its delivery as #305's. #305 then closed unmerged (its base branch had been +// deleted), and #315, opened from the same branch and head, merged it as 0914a33f. The delivery stayed stopped +// ("the pull request closed unmerged"), though it recorded merged_as 0914a33f: a rule did not hold — a delivery +// keyed by commit follows the pull request that merges that commit, and a closed duplicate does not end it — and +// the walk that carried the merge waited 64 minutes for this owner's word until a person's `plans go` released it. +// +// A delivery stopped only because its pull request closed unmerged, with no merge recorded, does not end its +// commit's delivery for another pull request with the same head: it is started over, under the same id, for the +// pull request that announces or merges it next. + +const ( + head783, merge783 = "d305d305d305d305", "d315d315d315d315" + head783b, merge783b = "d306d306d306d306", "d316d316d316d316" + head783c = "d307d307d307d307" + head783d, merge783d = "d308d308d308d308", "d318d318d318d318" + catalog783 = "novox/mesh-catalog" + branch783 = "adr-359-mesh-mail-733" +) + +// TestADeliveryStoppedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit is the issue's own shape: #305 +// announced, closed unmerged (stopped); #315 announced from the same head, checked and readied; #315 merges it, +// and the walk that answers that merge is let go — the delivery follows #315, never stuck on #305's stop. +func TestADeliveryStoppedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit(t *testing.T) { + w := newWorld(t) + id := IDOf(catalog783, head783) + + w.h.PullUpdated(pr(catalog783, 305, head783, branch783, "src/a.go")) + w.h.PullClosed(pr(catalog783, 305, head783, branch783)) + if w.state(id) != Stopped { + t.Fatalf("#305 closed unmerged left it %s; want stopped", w.state(id)) + } + + w.h.PullUpdated(pr(catalog783, 315, head783, branch783, "src/a.go")) + if d := w.delivery(id); d.State == Stopped || d.Number != 315 { + t.Fatalf("#315, the same head, left it %s, Number %d; want started over for #315, not stopped", d.State, d.Number) + } + if d := w.delivery(id); !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "#305 with the same head closed unmerged") { + t.Fatalf("the start-over's transition names what it replaces: %q", d.Transitions[len(d.Transitions)-1].Why) + } + + w.h.Checked(verdict(catalog783, 315, head783, "pass")) + if d := w.delivery(id); d.State != Ready { + t.Fatalf("a passing verdict for #315 left it %s", d.State) + } + + walk := w.ctl.put(aWalk("plan-783", catalog783, merge783, true)) + w.h.WalkMoved(walk) + w.h.PullMerged(merged(catalog783, 315, head783, merge783)) + if d := w.delivery(id); d.State != Published || d.Number != 315 { + t.Fatalf("#315's merge left it %s, Number %d; want published under #315", d.State, d.Number) + } + + w.settleAll() + if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-783" { + t.Fatalf("the walk that answers #315's merge was not let go: %v", w.ctl.delivers) + } + if d := w.delivery(id); d.State != Delivering { + t.Fatalf("let go, it is %s; want delivering", d.State) + } +} + +// TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo is decision 4: the merging pull request is never +// announced on its own (heard only as `pull.merged`) — the start-over happens in PullMerged itself, not only in +// PullUpdated, so a merge heard without the open announcement is not lost either. +func TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo(t *testing.T) { + w := newWorld(t) + id := IDOf(catalog783, head783b) + + w.h.PullUpdated(pr(catalog783, 306, head783b, branch783+"-b", "src/b.go")) + w.h.PullClosed(pr(catalog783, 306, head783b, branch783+"-b")) + if w.state(id) != Stopped { + t.Fatalf("#306 closed unmerged left it %s; want stopped", w.state(id)) + } + + walk := w.ctl.put(aWalk("plan-783b", catalog783, merge783b, true)) + w.h.WalkMoved(walk) + m := merged(catalog783, 316, head783b, merge783b) + m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"} + w.h.PullMerged(m) + if d := w.delivery(id); d.State == Stopped || d.Number != 316 { + t.Fatalf("#316's merge, heard with no announcement of its own, left it %s, Number %d; want started over for #316", + d.State, d.Number) + } + + w.settleAll() + if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-783b" { + t.Fatalf("the walk that answers #316's merge was not let go: %v", w.ctl.delivers) + } +} + +// TestAStoppedDeliveryAnnouncedAgainByItsOwnPullRequestStaysStopped guards decision 3's "any other stopped +// delivery ... is untouched": the same pull request announced again after its own close is the ordinary +// 'announced again' path, never a start-over, and stays stopped. +func TestAStoppedDeliveryAnnouncedAgainByItsOwnPullRequestStaysStopped(t *testing.T) { + w := newWorld(t) + id := IDOf(catalog783, head783c) + + w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go")) + w.h.PullClosed(pr(catalog783, 307, head783c, branch783+"-c")) + if w.state(id) != Stopped { + t.Fatalf("#307 closed unmerged left it %s; want stopped", w.state(id)) + } + + w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go")) + if d := w.delivery(id); d.State != Stopped || d.Number != 307 { + t.Fatalf("#307 announced again by itself left it %s, Number %d; want it to stay stopped", d.State, d.Number) + } +} + +// TestAMergeHeardLongAfterStartingOverAStoppedDuplicateIsHistoryLikeAnyOtherReplayedMerge is review round 1's +// decision: a delivery started over from a merge heard without its open announcement (as in decision 4, above) +// takes FromForge exactly as a first-heard merge does, so one replayed from the bus's history long after it +// happened still falls under the history rule (novox/hq issue 313) and is never held for a person. +func TestAMergeHeardLongAfterStartingOverAStoppedDuplicateIsHistoryLikeAnyOtherReplayedMerge(t *testing.T) { + w := newWorld(t) + id := IDOf(catalog783, head783d) + + w.h.PullUpdated(pr(catalog783, 308, head783d, branch783+"-d", "src/d.go")) + w.h.PullClosed(pr(catalog783, 308, head783d, branch783+"-d")) + if w.state(id) != Stopped { + t.Fatalf("#308 closed unmerged left it %s; want stopped", w.state(id)) + } + + m := merged(catalog783, 318, head783d, merge783d) + m.MergedAt = w.now.Add(-historyLate - time.Minute).UTC().Format(time.RFC3339) + w.h.PullMerged(m) + + d := w.delivery(id) + if d.State == Stopped || d.Number != 318 { + t.Fatalf("#318's merge, heard long after it happened with no announcement of its own, left it %s, Number %d; "+ + "want started over for #318", d.State, d.Number) + } + if !d.FromForge { + t.Fatal("a merge heard without its own announcement is made from the forge's word: FromForge is false") + } + if d.History == "" { + t.Fatalf("a merge heard %s after it happened is history, never held for a person; History is empty", historyLate+time.Minute) + } + if d.State == Held { + t.Fatalf("history is never held for a person; state is %s, HeldWhy %q", d.State, d.HeldWhy) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery/cmd/mesh-delivery/main.go index be753778..cd24e09b 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/main.go @@ -76,8 +76,9 @@ func run(h *Holder, l *listening) { logf("[mesh-delivery] the controller's walks cannot be read: %v", err) } } - // Every open pull request, from the forge itself, every five minutes (novox/hq issue 347). - if now.Sub(looked) >= 5*time.Minute { + // Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a + // head nobody checks, and the ready marks a merge is asked on. + if now.Sub(looked) >= time.Minute { looked = now h.SeeOpenPulls() } diff --git a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go b/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go index 6b4800f7..ade98c14 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go @@ -65,7 +65,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { want = append(want, "seat:"+ControllerSeat+"."+v) } want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", - "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls") + "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls", + // The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off. + "gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api") if !reflect.DeepEqual(m.Invokes, want) { t.Fatalf("invokes %v, wanted %v", m.Invokes, want) } diff --git a/modules/mesh-delivery/cmd/mesh-delivery/model.go b/modules/mesh-delivery/cmd/mesh-delivery/model.go index 1f25263b..2d2a0c67 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/model.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/model.go @@ -83,6 +83,14 @@ type Delivery struct { // CoverTold is whether the forge was told that such a stop was covered since: its status, its view and its note // (novox/hq issue 480). Kept, so a restart tells it once, and a stop covered before this was built is told too. CoverTold bool `json:"cover_told,omitempty"` + // DeliveredBy says, for a delivery that failed or was stopped, the later finished walk of its repository whose + // commit contains its merge and which built every module it moves — each it did not build, its own walk said + // unchanged: its change reached the machines after all (novox/hq issue 747). Its state stays final, as a covered + // stop's does (issue 480), and it reads covered: its group counts it as delivered. + DeliveredBy string `json:"delivered_by,omitempty"` + // StoppedFor is, for a member its group stopped, the member whose end stopped it (novox/hq issue 747): once that + // member's change reached the machines, the stop has lapsed, and holds no walk of its repository back. + StoppedFor string `json:"stopped_for,omitempty"` // TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it, // which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9, // novox/hq issue 445). `show` shows both, and `stalled` says them past that moment. @@ -110,6 +118,9 @@ type Delivery struct { LetGo time.Time `json:"let_go"` LetGoWhy string `json:"let_go_why,omitempty"` + // Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go). + Ready *ReadyRun `json:"ready,omitempty"` + Transitions []Transition `json:"transitions,omitempty"` // Owed are the forge's and the bus's effects of its transitions not yet done: said only after the // delivery is kept, retried until done. @@ -300,18 +311,77 @@ func (d *Delivery) FromForgesWord() bool { // shownOnItsPullRequest is whether its view and status are kept on its pull request: not for history, whose // pull request the forge may no longer hold — or holds under its number another one. -// coveredStop is whether a delivery stopped because its walk ended done before its word without building its modules -// (novox/hq issue 474) was covered since: a later merge into its trunk that contains it was delivered, carrying every -// module it moves to every machine running it (ADR 0270's cover test, kept as CoveredBy). Its state stays stopped — -// final, as a covered superseded member stays superseded (issue 442) — and it reads covered, by that merge (novox/hq -// issue 480). A stop for any other reason never reads covered. +// coveredStop is whether a delivery that ended stopped or failed was covered since, and reads covered. A stop +// because its walk ended done before its word without building its modules (novox/hq issue 474) is covered when a +// later merge into its trunk that contains it was delivered, carrying every module it moves to every machine running +// it (ADR 0270's cover test, kept as CoveredBy; issue 480). Any stop or failure is covered when a later finished walk +// of its repository whose commit contains its merge built its modules (DeliveredBy, novox/hq issue 747). Its state +// stays final, as a covered superseded member stays superseded (issue 442), and it reads covered, by that merge or +// walk. A person's stop with a cover found only while it was held keeps its error. func (d *Delivery) coveredStop() bool { - return d.State == Stopped && d.Stranded != "" && d.CoveredBy != "" + switch d.State { + case Stopped: + return d.Stranded != "" && d.CoveredBy != "" || d.DeliveredBy != "" + case Failed: + return d.DeliveredBy != "" + } + return false } -// coveredStopSaid is how a covered stop reads: on the forge's status, in its view and note, and in `show`. +// coveredStopSaid is how a covered stop or failure reads: on the forge's status, in its view and note, and in `show`. func (d *Delivery) coveredStopSaid() string { - return "covered after its stop: a later merge that contains it was delivered: " + d.CoveredBy + ended := "its stop" + if d.State == Failed { + ended = "its failure" + } + if d.Stranded != "" && d.CoveredBy != "" { + return "covered after " + ended + ": a later merge that contains it was delivered: " + d.CoveredBy + } + return "covered after " + ended + ": a later walk that contains it was delivered: " + d.DeliveredBy +} + +// memberReached is whether a group counts a member as delivered: its change reached the machines, or had nothing to +// reach them with — delivered, history or covered, a superseded member a later delivered walk carried (issue 442), +// or a stop or failure covered since (issues 480 and 747). +func memberReached(d *Delivery) bool { + return d.State.Reached() || d.State == Superseded && d.CoveredBy != "" || d.coveredStop() +} + +// stoppedFor is the member whose end stopped this one in its group: kept since novox/hq issue 747, and read from the +// stop's reason for a stop made before ("its group stopped: …"). +func (d *Delivery) stoppedFor() string { + if d.StoppedFor != "" || d.State != Stopped || d.Group == "" { + return d.StoppedFor + } + prefix := "its group " + d.Group + " stopped: " + for i := len(d.Transitions) - 1; i >= 0; i-- { + t := d.Transitions[i] + if t.Event != EvStop { + continue + } + if t.By != byOwner || !strings.HasPrefix(t.Why, prefix) { + return "" + } + member, _, _ := strings.Cut(strings.TrimPrefix(t.Why, prefix), " ") + return strings.TrimRight(member, ",;:") + } + return "" +} + +// stopLapsed is whether a member its group stopped for another member is stopped for nothing any more: that member's +// change reached the machines since — delivered, or its failure covered by a later walk (novox/hq issue 747). Its own +// change is still to deliver, by a later walk of its repository that contains it; its stop holds none back. find +// gives a delivery by its id. +func stopLapsed(d *Delivery, find func(string) *Delivery) bool { + if d.State != Stopped || d.coveredStop() { + return false + } + by := d.stoppedFor() + if by == "" || by == d.ID { + return false + } + o := find(by) + return o != nil && memberReached(o) } func (d *Delivery) shownOnItsPullRequest() bool { diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ports.go b/modules/mesh-delivery/cmd/mesh-delivery/ports.go index a4727928..63fb608f 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/ports.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/ports.go @@ -91,6 +91,13 @@ type Forge interface { // OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue // 347). Reads only. OpenPulls() ([]OpenPull, error) + // Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq + // issue 722): the one path every merge takes. Its refusal is the error, in its words. + Merge(owner, repo string, number int, head string) error + // Comment adds a comment to a pull request. + Comment(owner, repo string, number int, body string) error + // Unlabel takes a label off a pull request, by the label's id. + Unlabel(owner, repo string, number, label int) error } // ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses). @@ -392,6 +399,19 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string) return &c, nil } +func (f toolForge) Merge(owner, repo string, number int, head string) error { + return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head}) +} + +func (f toolForge) Comment(owner, repo string, number int, body string) error { + return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body}) +} + +func (f toolForge) Unlabel(owner, repo string, number, label int) error { + return f.tool("gitea_api", map[string]any{"method": "DELETE", + "path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)}) +} + func (f toolForge) OpenPulls() ([]OpenPull, error) { raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{}) if err != nil { diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ready.go b/modules/mesh-delivery/cmd/mesh-delivery/ready.go new file mode 100644 index 00000000..f5488f25 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/ready.go @@ -0,0 +1,323 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +// Merge on ready (novox/hq issue 722). +// +// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case". +// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again +// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request +// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at " (approving +// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool +// (gitea_merge_pull_request), the one path every merge takes: +// +// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears +// it — the label taken off, and said on the pull request — and the new head needs a mark of its own; +// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged +// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge +// is asked again once a minute, and lands on the passing verdict; +// - a group member only at its turn in the group's order, and only once the group's composed check passed for its +// members' heads as they are; +// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the +// controller raises as the condition `delivery..stalled`. So is a mark the merge tool refused for an hour. +// +// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name +// it as their author. + +// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the +// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and +// no comment names a head. +type ReadyMark struct { + Head string `json:"head"` + By string `json:"by,omitempty"` + At string `json:"at,omitempty"` + LabelID int `json:"label_id"` +} + +// ReadyLabel is the label that marks a pull request ready. +const ReadyLabel = "mesh/ready" + +// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes +// minutes, and a refusal costs the forge a few reads. +const readyTryEvery = time.Minute + +// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a +// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said. +const readyRefusedFor = time.Hour + +// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`. +type ReadyRun struct { + // Head, By and MarkedAt are the mark: the head it names, who marked it, when. + Head string `json:"head"` + By string `json:"by,omitempty"` + MarkedAt string `json:"marked_at,omitempty"` + // Seen is when this owner first read the mark. + Seen time.Time `json:"seen"` + // Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check. + Waits string `json:"waits,omitempty"` + // Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered. + Tries int `json:"tries,omitempty"` + LastTry time.Time `json:"last_try"` + Answer string `json:"answer,omitempty"` + // RefusedSince is when the merge tool began refusing it without a break. + RefusedSince *time.Time `json:"refused_since,omitempty"` + // Merged is when the merge tool merged it. + Merged *time.Time `json:"merged,omitempty"` + // Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request. + Failed string `json:"failed,omitempty"` +} + +// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions. +func (r *ReadyRun) mergedBy() string { + if r == nil || r.Merged == nil { + return "" + } + by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12) + if r.By != "" { + by += " by " + r.By + } + return by +} + +// marked is a pull request read with a ready mark. +type marked struct { + pull OpenPull + // cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery. + cleared time.Time +} + +// pullKey is a pull request as a key: owner/repo#number. +func pullKey(p OpenPull) string { + return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number) +} + +// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or +// no longer marked drops out. +func (h *Holder) seeMarks(open []OpenPull) { + seen := map[string]*marked{} + for _, p := range open { + if p.Ready == nil || p.HeadSHA == "" { + continue + } + k := pullKey(p) + m := &marked{pull: p} + if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head && + was.pull.HeadSHA == p.HeadSHA { + m.cleared = was.cleared + } + seen[k] = m + } + h.marks = seen +} + +// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve +// characters of it. +func sameFull(mark, head string) bool { + mark, head = strings.ToLower(mark), strings.ToLower(head) + return len(mark) >= 12 && strings.HasPrefix(head, mark) +} + +// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said. +// The asking happens outside the lock. +func (h *Holder) readyAsks(now time.Time) []func() { + var asks []func() + for k, m := range h.marks { + p, mark := m.pull, *m.pull.Ready + repository := strings.ToLower(p.Owner + "/" + p.Repo) + if !sameFull(mark.Head, p.HeadSHA) { + if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery { + continue + } + m.cleared = now + asks = append(asks, h.clearMark(k, p, mark)) + continue + } + d := h.deliveries[IDOf(repository, p.HeadSHA)] + if d == nil || d.State.Final() || d.MergedAs != "" { + continue // not announced yet, or past its merge + } + if d.Ready == nil || d.Ready.Head != mark.Head { + d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now} + h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head", + d.ID, shortOf(mark.Head, 12), orNone(mark.By)) + h.keep(d) + } + r := d.Ready + if r.Merged != nil { + continue + } + if d.State == Rejected { + if r.Failed == "" { + r.Failed = "its check failed" + if d.Check != nil { + r.Failed += ": " + oneLine(d.Check.Summary) + if j := d.Check.Judged(); j != "" { + r.Failed += " (" + j + ")" + } + } + r.Waits = "" + h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed) + h.keep(d) + asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+ + "ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.", + mark.Head, r.Failed))) + } + continue + } + r.Failed = "" + waits := h.readyWaits(d) + if waits != r.Waits { + r.Waits = waits + if waits != "" { + h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits) + } + h.keep(d) + } + if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) { + continue + } + r.Tries++ + r.LastTry = now + h.keep(d) + asks = append(asks, h.askMerge(d.ID, p, mark.Head)) + } + return asks +} + +// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is +// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it. +func (h *Holder) readyWaits(d *Delivery) string { + switch { + case d.State == Ready: + case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil: + case d.State == Proposed && d.AskedAgain != nil: + return "its check against the trunk's head is asked and has not answered" + default: + return "its check has not passed yet (" + string(d.State) + ")" + } + if d.Group == "" { + return "" + } + return h.groupTurnToMerge(d) +} + +// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for +// its members' heads as they are and is not out of date, and every member before it in the group's order merged. +func (h *Holder) groupTurnToMerge(d *Delivery) string { + g := h.groups[d.Group] + if g == nil { + return "" + } + members := h.membersOf(g) + if state, why := GroupState(g, members); state != "ready" && state != "delivering" { + return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why) + } + if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" || + !sameMembers(c.Commits, commitsOf(members)) { + return "its group " + g.ID + "'s composed check has not passed for its members' heads" + } + if !containsString(g.Order, d.ID) { + return "its group " + g.ID + "'s order is not worked out for it yet" + } + for _, id := range g.Order { + if id == d.ID { + return "" + } + if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() { + return "its group " + g.ID + "'s order merges " + id + " first" + } + } + return "" +} + +// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer. +func (h *Holder) askMerge(id string, p OpenPull, head string) func() { + return func() { + err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head) + h.mu.Lock() + defer h.mu.Unlock() + d := h.deliveries[id] + if d == nil || d.Ready == nil { + return + } + r, now := d.Ready, h.Now() + if err == nil { + r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil + h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id, + shortOf(head, 12)) + h.keep(d) + return + } + answer := oneLine(err.Error()) + if r.RefusedSince == nil { + r.RefusedSince = &now + } + if answer != r.Answer { + h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id, + readyTryEvery, answer) + } + r.Answer = answer + h.keep(d) + } +} + +// clearMark takes a mark off a pull request whose head moved after it, and says so on it. +func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() { + return func() { + err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID) + if err == nil { + err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+ + "the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA)) + } + if err != nil { + h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v", + k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err) + return + } + h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12), + shortOf(p.HeadSHA, 12)) + h.mu.Lock() + delete(h.marks, k) + h.mu.Unlock() + } +} + +// sayOn comments on a delivery's pull request. +func (h *Holder) sayOn(d *Delivery, body string) func() { + owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID + return func() { + if err := h.Forge.Comment(owner, repo, number, body); err != nil { + h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err) + } + } +} + +// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound; +// as stalled lines, which the controller raises as conditions. Under the lock. +func (h *Holder) readyLines(now time.Time) []StalledLine { + var out []StalledLine + for _, d := range h.deliveries { + r := d.Ready + if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() { + continue + } + switch { + case r.Failed != "" && d.State == Rejected: + out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(), + Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's", + Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number, + shortOf(r.Head, 12), r.Failed)}) + case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor: + out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, + For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(), + H2: "none: the merge is asked again each minute — the operator's", + Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository, + d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)}) + } + } + return out +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ready_test.go b/modules/mesh-delivery/cmd/mesh-delivery/ready_test.go new file mode 100644 index 00000000..e818e9b1 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/ready_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with +// green checks waited until a session called the merge tool, and again after every check the tool asked because the +// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it. + +const ( + readyHead = "1111111111111111111111111111111111111111" + pushed = "2222222222222222222222222222222222222222" +) + +// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named. +func markedPull(repo string, number int, head, markedAt string) OpenPull { + owner, name, _ := strings.Cut(repo, "/") + return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel}, + Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}} +} + +func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent { + c := verdict(repo, number, sha, gate) + c.Trunk = trunk + return c +} + +func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) { + w := newWorld(t) + id := IDOf("novox/app", readyHead) + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) + w.settleAll() + if len(w.forge.merges) != 0 { + t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges) + } + w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)} + w.h.SeeOpenPulls() + w.settleAll() + if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead { + t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges) + } + d := w.delivery(id) + if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 { + t.Fatalf("what was done with the mark is not kept: %+v", d.Ready) + } + // Its merge's transition names who merged it. + w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7")) + w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true))) + w.settleAll() + d = w.delivery(id) + var last Transition + for _, tr := range d.Transitions { + if tr.Event == EvMerged { + last = tr + } + } + if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") { + t.Fatalf("the merge's transition does not name the mark: %+v", last) + } + if len(w.forge.merges) != 1 { + t.Fatalf("merged again: %v", w.forge.merges) + } +} + +func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go")) + w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1")) + w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)} + w.h.SeeOpenPulls() + w.settleAll() + if len(w.forge.merges) != 0 { + t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges) + } + if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" { + t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels) + } + if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") || + !strings.Contains(w.forge.comments[0], pushed) { + t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments) + } +} + +func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) { + w := newWorld(t) + id := IDOf("novox/app", readyHead) + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) + // Another merge moves the trunk: its verdict is out of date, and it goes back to proposed. + w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2")) + if w.state(id) != Proposed { + t.Fatalf("out of date, it is %s", w.state(id)) + } + // The merge tool asks the check against the trunk's head and refuses: nothing merged. + w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked") + w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)} + w.h.SeeOpenPulls() + w.settleAll() + if len(w.forge.merges) != 1 { + t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges) + } + // The tool's ask is the head announced again; it waits for that verdict, asking nothing. + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.later(5 * time.Minute) + w.settleAll() + if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") { + t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready) + } + // It passes against the trunk's head: merged, without anyone asking. + w.forge.refuse = nil + w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2")) + w.settleAll() + if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil { + t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready) + } +} + +func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) { + w := newWorld(t) + id := IDOf("novox/app", readyHead) + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1")) + w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)} + w.h.SeeOpenPulls() + w.settleAll() + w.later(10 * time.Minute) + w.h.SeeOpenPulls() + w.settleAll() + if len(w.forge.merges) != 0 { + t.Fatalf("a head whose check failed was merged: %v", w.forge.merges) + } + if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") { + t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments) + } + lines := w.h.Stalled() + if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") { + t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines) + } +} + +func TestAMarkRefusedForAnHourIsSaid(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) + w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending") + w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)} + w.h.SeeOpenPulls() + w.settleAll() + w.later(30 * time.Second) + w.settleAll() + if len(w.forge.merges) != 1 { + t.Fatalf("asked again within the minute: %v", w.forge.merges) + } + w.later(readyRefusedFor) + w.settleAll() + if len(w.forge.merges) != 2 { + t.Fatalf("not asked again after a minute: %v", w.forge.merges) + } + if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") { + t.Fatalf("a mark refused for an hour is not said: %+v", lines) + } +} + +func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) { + w := newWorld(t) + const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2" + ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead) + w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} } + w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) + w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) + w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1")) + w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3")) + w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead), + markedPull("novox/mesh-catalog", 2, catHead, catHead)} + w.h.SeeOpenPulls() + w.settleAll() + if len(w.forge.merges) != 0 { + t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges) + } + w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"}) + w.settleAll() + if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead { + t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges) + } + if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") { + t.Fatalf("the second member does not say it waits its turn: %q", waits) + } + w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1")) + w.settleAll() + if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead { + t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go b/modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go index 81d618d5..97c843c8 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" ) // A recheck puts the merge check's statuses back to pending before it asks the check: the forge keeps the @@ -95,3 +96,80 @@ func TestARecheckTheForgeCannotTakeIsRefused(t *testing.T) { t.Fatalf("refused, it moved to %s", s) } } + +// novox/hq issue 867, ADR 0323 §6: "the composed check runs again when a member is rechecked". A group whose composed +// check failed stayed rejected after a member was rechecked and passed, because the kept verdict was for the same heads +// and nothing marked it out of date. +func TestAMembersRecheckAsksTheGroupsComposedCheckAgain(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")) + w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z")) + w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass")) + w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) + w.settleAll() + groupAsks := func() (n int) { + for _, c := range w.ctl.checks { + if strings.HasPrefix(c, "feat/z:") { + n++ + } + } + return + } + if groupAsks() != 1 { + t.Fatalf("the group was asked %v", w.ctl.checks) + } + w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"}) + w.settleAll() + if state, _ := GroupState(w.h.groups["feat/z"], w.h.membersOf(w.h.groups["feat/z"])); state != "rejected" { + t.Fatalf("the group is %s", state) + } + if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil { + t.Fatal(err) + } + // The member's own check passes again, at the same head, a while later. + w.later(5 * time.Minute) + w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) + w.settleAll() + if groupAsks() != 2 { + t.Fatalf("a member's recheck did not ask the group again: %v", w.ctl.checks) + } + if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.Asked == "check-1" { + t.Fatalf("the old verdict still stands: %+v", g.Check) + } +} + +// A recheck made while the group's composed check is still running is not lost: the old run's answer lands, and the +// group is still asked again once the member is ready. +func TestARecheckWhileTheComposedCheckRunsAsksTheGroupAgain(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")) + w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z")) + w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass")) + w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) + w.settleAll() // the composed check check-1 is asked and running: no verdict yet + if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil { + t.Fatal(err) + } + // The old run answers. + w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"}) + w.later(5 * time.Minute) + w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) + w.settleAll() + n := 0 + for _, c := range w.ctl.checks { + if strings.HasPrefix(c, "feat/z:") { + n++ + } + } + if n != 2 { + t.Fatalf("the group was not asked again after a recheck made while its check ran: %v", w.ctl.checks) + } + if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.OutOfDate != "" { + t.Fatalf("the old run's answer stands: %+v", g.Check) + } + // The old run answering again is history. + w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "late"}) + if g := w.h.groups["feat/z"]; g.Check.Verdict != "" { + t.Fatalf("a late answer of the old run was taken: %+v", g.Check) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/table.go b/modules/mesh-delivery/cmd/mesh-delivery/table.go index 687f1b2c..9f9a6749 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/table.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/table.go @@ -498,7 +498,12 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) { if why == "" { why = r.Guard } - t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By} + by := f.By + if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" { + // A merge this owner made on a ready mark names it as its author (novox/hq issue 722). + by = d.Ready.mergedBy() + } + t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by} d.State, d.Since = r.To, f.Now d.Transitions = append(d.Transitions, t) if len(d.Transitions) > keptTransitions { diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go index 2947b823..14cc5d20 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go @@ -72,6 +72,9 @@ type OpenPull struct { // than that says none: the head is then read by Statuses alone, as before, and a pending check is not told // from an answered one. Checks map[string]HeadCheck `json:"checks,omitempty"` + // Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722). + Labels []string `json:"labels,omitempty"` + Ready *ReadyMark `json:"ready,omitempty"` } // HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning. @@ -201,6 +204,7 @@ func (h *Holder) SeeOpenPulls() { seen[id] = u } h.unchecked = seen + h.seeMarks(open) } // started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery diff --git a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go index bcaa8e57..e44c2236 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go @@ -72,6 +72,10 @@ func (h *Holder) waitsFor(d *Delivery) string { return "its walk: " + clip(d.Walk.Note, 120) } case Stopped: + if h.stopLapsed(d) { + return "a later walk of its repository that contains its merge: its group stopped it for " + d.stoppedFor() + + ", whose change was delivered since (novox/hq issue 747)" + } if strandedUncovered(d) { return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " + "listed by stalled until one does (novox/hq issue 480)" @@ -150,6 +154,21 @@ type GroupLine struct { Contradictions []Contradiction `json:"contradictions,omitempty"` Check string `json:"composed,omitempty"` Members []string `json:"members"` + // Details are the members again, one object each, with what a reader needs to find the pull request's + // siblings: its number, full head, base branch and whether it is open (novox/hq ADR 0382 §1). Additive: + // `members` keeps its text form for older readers. + Details []MemberLine `json:"member_details,omitempty"` +} + +// MemberLine is one member of a group, as `groups` lists it beside its text line. +type MemberLine struct { + ID string `json:"id"` + Repository string `json:"repository"` + State State `json:"state"` + Number int `json:"number"` + Head string `json:"head"` + Base string `json:"base"` + Open bool `json:"open"` } // Groups is the `groups` verb. @@ -170,6 +189,9 @@ func (h *Holder) Groups(all bool) []GroupLine { Contradictions: g.Contradictions} for _, d := range members { l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State)) + l.Details = append(l.Details, MemberLine{ID: d.ID, Repository: d.Repository, State: d.State, + Number: d.Number, Head: d.Commit, Base: d.Base, + Open: !d.State.Final() && d.MergedAs == "" && !d.ClosedUnmerged}) } if g.Check != nil { l.Check = g.Check.Verdict @@ -249,6 +271,7 @@ func (h *Holder) Stalled() []StalledLine { } out = append(out, h.withChecks(h.uncheckedLines(now))...) out = append(out, h.orphanLines(now)...) + out = append(out, h.readyLines(now)...) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } @@ -287,6 +310,16 @@ func (h *Holder) Recheck(id, why, by string) (string, error) { return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err) } d.Check = nil + // The composed check of its group goes out of date (novox/hq ADR 0323 §6, issue 867): it was judged for these very + // heads (running or answered: a run's answer is taken by its id and must not stand for the recheck), so without this the kept verdict (a failure above all) stands and the group is never asked again. It is + // asked again once the member is judged again, when every member is ready. + if g := h.groups[d.Group]; g != nil && g.Check != nil { + now := h.Now() + g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why) + g.Check.OutOfDateAt = now + g.Updated = now + h.keepGroup(g) + } h.moved(d, []Transition{t}) m := MemberOf(d) h.mu.Unlock() diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery/module.json index 681165fe..4c486145 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery/module.json @@ -47,7 +47,10 @@ "gitea.gitea_commit_status", "gitea.gitea_commit_statuses", "gitea.gitea_contains", - "gitea.gitea_open_pulls" + "gitea.gitea_open_pulls", + "gitea.gitea_merge_pull_request", + "gitea.gitea_add_comment", + "gitea.gitea_api" ], "state": [ "deliveries", diff --git a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go index 1678c03e..15635181 100644 --- a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go @@ -25,9 +25,9 @@ type manifest struct { Logs []string `json:"logs"` Tools []string `json:"tools"` Resources []struct { - ID string `json:"id"` - Path string `json:"path"` - Content string `json:"content"` + ID string `json:"id"` + Path string `json:"path"` + Template string `json:"template"` } `json:"resources"` Build struct { Artifacts []struct { @@ -109,7 +109,8 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) { "gitea.gitea_pull_reviews", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) { t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) } - if len(m.Build.Artifacts) != 1 || m.Build.Artifacts[0].From != "cmd/mesh-issues" || + // The tools bundle, and the archive the build packs files/ into for the templates (novox/hq ADR 0349 §2). + if len(m.Build.Artifacts) != 2 || m.Build.Artifacts[0].From != "cmd/mesh-issues" || m.Build.Artifacts[1].From != "files" || m.Build.Artifacts[0].Env["MESH_ISSUES_SETTINGS"] == "" || !reflect.DeepEqual(m.Build.Artifacts[0].Loads, []string{"mesh-issues"}) { t.Fatalf("build %+v", m.Build) } @@ -145,7 +146,7 @@ func TestTheSettingsStartTheCounterAndRegisterTheOwners(t *testing.T) { var content string for _, r := range m.Resources { if r.ID == "settings" { - content = r.Content + content = templateText(t, map[string]any{"id": r.ID, "template": r.Template}) } } var s Settings diff --git a/modules/mesh-issues/cmd/mesh-issues/templates_test.go b/modules/mesh-issues/cmd/mesh-issues/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/mesh-issues/files/settings.json b/modules/mesh-issues/files/settings.json new file mode 100644 index 00000000..32e4ed00 --- /dev/null +++ b/modules/mesh-issues/files/settings.json @@ -0,0 +1,146 @@ +{ + "first-number": 365, + "owners": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/build-agent", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/de-spiegel", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/route-proxy", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools" + ], + "duplicate-check": "on", + "judge-model": "sonnet", + "forge-owner": "novox", + "stuck-after": "2h", + "worker-stale-after": "24h", + "delivered-not-resolved-after": "1h", + "decision-floor": 293, + "research-floor": 45 +} diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index a6312a6a..4036a0c7 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -81,6 +81,208 @@ "grooming", "reservations" ], + "config": { + "first-number": { + "type": "number", + "description": "The number the first issue the tracker numbers gets, read once when its counter starts and never again; the import reads it as the boundary between the folders hq numbered and the tracker's issues.", + "minimum": 1, + "trusted": true, + "default": 365 + }, + "owners": { + "type": "list", + "description": "The registered places an issue's located-in may name: a repository, or a repository and a folder in it separated by a space.", + "trusted": true, + "default": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/build-agent", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/de-spiegel", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/route-proxy", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools" + ] + }, + "duplicate-check": { + "type": "string", + "description": "Whether a new issue is checked against the open ones for a duplicate: off stops it, anything else is on (novox/hq ADR 0278 §7).", + "trusted": true, + "default": "on" + }, + "judge-model": { + "type": "string", + "description": "The model the duplicate check asks, by the agent's name for it, such as sonnet; empty is the tracker's own default.", + "trusted": true, + "default": "sonnet" + }, + "forge-owner": { + "type": "string", + "description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.", + "trusted": true, + "default": "novox" + }, + "stuck-after": { + "type": "string", + "description": "How long a fix may be merged and not delivered before it is said stuck, as a duration such as 2h (novox/hq ADR 0281); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "2h" + }, + "worker-stale-after": { + "type": "string", + "description": "How long an issue's worker stays current with nothing done on it, as a duration such as 24h (novox/hq ADR 0284 §4); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "24h" + }, + "delivered-not-resolved-after": { + "type": "string", + "description": "How long an issue may stay in fixing once every fix it names is delivered before the operator is warned, as a duration such as 1h (novox/hq issue 483); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "1h" + }, + "decision-floor": { + "type": "number", + "description": "The highest decision number already taken somewhere the tracker cannot read, such as an open branch of hq; the decision sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 293 + }, + "research-floor": { + "type": "number", + "description": "The highest research number already taken somewhere the tracker cannot read; the research sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 45 + } + }, "resources": [ { "id": "state", @@ -94,8 +296,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["first-number", "owners", "duplicate-check", "judge-model", "forge-owner", "stuck-after", "worker-stale-after", "delivered-not-resolved-after", "decision-floor", "research-floor"], "merge": "json", - "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\",\n \"worker-stale-after\": \"24h\",\n \"delivered-not-resolved-after\": \"1h\",\n \"decision-floor\": 293,\n \"research-floor\": 45\n}\n" + "template": "files/settings.json" } ], "build": { @@ -113,6 +316,11 @@ "env": { "MESH_ISSUES_SETTINGS": "${dir:state}/settings.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index a8d11cc3..8bef489d 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -21,7 +21,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that | `links` | `number`, `kind` | the record's typed links, out and in | | `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line | | `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted | -| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`). Each version proposed is then reviewed by the knowledge base itself (below) | +| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal. Each version proposed is then reviewed by the knowledge base itself (below) | | `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number | | `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number | diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go new file mode 100644 index 00000000..3302dec0 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// effortAsk is a research effort proposal with the given documents. +func effortAsk(n int, status string, docs ...Section) ProposeAsk { + return ProposeAsk{Kind: KindResearch, Number: n, Title: "A vendor-neutral inference layer", Date: "2026-10-11", + Deciders: []string{"jochen"}, Status: status, Sections: docs, + Links: []LinkAsk{{Kind: LinkTouches, To: "0290"}}} +} + +// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal. +func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) { + w := imported(t, newMemStore()) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + if err != nil { + t.Fatal(err) + } + overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."} + w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview)) + key := Key{KindResearch, r.Number} + reviews := len(w.judge.reviews) + next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."}) + next.Why = "findings added" + if _, err := w.k.Propose("author@g14", next); err != nil { + t.Fatalf("an active effort refused a new version: %v", err) + } + e := mustGet(t, w.k.Store, key) + if e.Pending == nil || e.Current.Section("01-findings.md") != "" { + t.Fatalf("the new version was served before its review") + } + w.reviewAll(t) + q := w.judge.reviews[reviews] + if strings.Contains(q, "a correction of fact (a progressive insight)") { + t.Fatalf("the review judged a new version as an insight:\n%.400s", q) + } + e = mustGet(t, w.k.Store, key) + if e.Pending != nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "A new finding." || + len(e.Current.Insights) != 0 { + t.Fatalf("after the review: %s, pending %v, insights %v", e.Current.Status, e.Pending != nil, e.Current.Insights) + } + h, err := w.k.History(key, 0, 0, 0) + if err != nil { + t.Fatal(err) + } + for _, v := range h.Versions { + if v.Insight { + t.Errorf("version %d is marked an insight", v.N) + } + } +} + +func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) { + w := imported(t, newMemStore()) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + if err != nil { + t.Fatal(err) + } + overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."} + grad := effortAsk(r.Number, StatusGraduated, overview) + grad.Links = append(grad.Links, LinkAsk{Kind: LinkBecame, To: "0290"}) + w.accepted(t, "author@g14", grad) + next := effortAsk(r.Number, StatusGraduated, overview, Section{Name: "01-findings.md", Text: "More."}) + next.Links = grad.Links + if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "progressive insight") { + t.Fatalf("a graduated effort took a plain version: %v", err) + } + n := w.reserved(t, "author@g14") + w.accepted(t, "author@g14", valid(n)) + p := valid(n) + p.Sections[2].Text = "Changed." + if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") { + t.Fatalf("a decision took a plain version: %v", err) + } +} + +// An old active effort's new text is held to the rules as of the day it is proposed, not the day the effort began. +func TestANewVersionOfAnOldActiveEffortIsHeldToTodaysRules(t *testing.T) { + w := imported(t, newMemStore()) + w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort") + if err != nil { + t.Fatal(err) + } + overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."} + first := effortAsk(r.Number, StatusActive, overview) + first.Date = "2026-10-01" + w.accepted(t, "author@g14", first) + w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "The control plane wrote them."}) + next.Date = "2026-10-01" + if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "retired-word") { + t.Fatalf("a retired word in new text of an old effort: %v", err) + } +} + +func TestAChangesVerdictLeavesTheServedVersionServedAndGraduatingNeedsABecameLink(t *testing.T) { + w := imported(t, newMemStore()) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + if err != nil { + t.Fatal(err) + } + overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."} + w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview)) + key := Key{KindResearch, r.Number} + w.judge.review = `{"verdict":"changes","findings":["01-findings.md: say where the finding comes from"]}` + next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."}) + if _, err := w.k.Propose("author@g14", next); err != nil { + t.Fatal(err) + } + w.reviewAll(t) + e := mustGet(t, w.k.Store, key) + if e.Pending == nil || e.Pending.Reviews[len(e.Pending.Reviews)-1].Verdict != VerdictChanges || + e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" { + t.Fatalf("after changes: pending %v, current %+v", e.Pending != nil, e.Current.Sections) + } + grad := effortAsk(r.Number, StatusGraduated, overview) + if _, err := w.k.Propose("author@g14", grad); err == nil || !strings.Contains(err.Error(), "became") { + t.Fatalf("graduated without a became link: %v", err) + } +} + +// An earlier marked insight in an old effort's text does not predate the effort, and a new version is accepted. +func TestAnOldActiveEffortWithAMarkedInsightTakesANewVersion(t *testing.T) { + w := imported(t, newMemStore()) + w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort") + if err != nil { + t.Fatal(err) + } + overview := Section{Name: "00-overview.md", Text: "What an inference layer would be.\n\n**Progressive insight — 2026-10-05.** A fact was corrected."} + first := effortAsk(r.Number, StatusActive, overview) + first.Date = "2026-10-01" + w.accepted(t, "author@g14", first) + w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."}) + next.Date = "2026-10-01" + if _, err := w.k.Propose("author@g14", next); err != nil { + t.Fatalf("a new version with an earlier marked insight: %v", err) + } + w.reviewAll(t) + e := mustGet(t, w.k.Store, Key{KindResearch, r.Number}) + if e.Pending != nil || e.Current.Section("01-findings.md") != "A new finding." || e.Current.Date != "2026-10-01" { + t.Fatalf("after the review: pending %v, date %s", e.Pending != nil, e.Current.Date) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go index d632437a..6245228e 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go @@ -357,7 +357,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) { g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor) } if e.Pending != nil && a.Version == 0 { - g.Pending = fmt.Sprintf("version %d, a progressive insight, waits for its review: history shows what it changes", e.PendingVersion) + g.Pending = fmt.Sprintf("version %d, a progressive insight or a new version of an active effort, waits for its review: history shows what it changes", e.PendingVersion) } if r.Ask != nil { g.Waiting = "the operator's ask: " + r.Ask.Said diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index f82917b8..4c469359 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -335,7 +335,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { return nil, fmt.Errorf("%s has a progressive insight by %s waiting for its review: only its author, proven by the "+ "runtime, revises it (this call is by %s). Nothing was proposed", key, held.Pending.Author, caller) } - if strings.TrimSpace(a.Insight) == "" { + // An active research effort is still being researched: it takes new versions freely, each reviewed as a + // proposal while the served version stays (novox/hq issue 844). Every other accepted record needs an insight. + revision := a.Kind == KindResearch && held.Current.Status == StatusActive && strings.TrimSpace(a.Insight) == "" + if strings.TrimSpace(a.Insight) == "" && !revision { return nil, fmt.Errorf("%s is %s: it changes only by a new record, or by a progressive insight, a correction of fact "+ "that leaves the decision standing (give insight: what stood before). Nothing was proposed", key, held.Current.Status) } @@ -349,8 +352,14 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { r.Pending = &PendingState{Insight: Insight{Date: k.now().Format("2006-01-02"), Before: strings.TrimSpace(a.Insight), By: caller}} r.Date = held.Current.Date r.Effort = a.Status + if revision { + r.Pending = &PendingState{} + } if why == "" { why = "a progressive insight proposed" + if revision { + why = "a new version proposed" + } } default: return nil, fmt.Errorf("%s is %s and takes no proposal; nothing was proposed", key, held.Working().Status) @@ -370,7 +379,13 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { if !current && a.Kind == KindResearch && r.Effort == "" { r.Effort = held.Current.Status } - refusals = append(refusals, JudgeRecord(r, held, k.lookup(ctx), ref)...) + // A new version of an active effort is new text: the dated rules hold it from the day it is proposed, while the + // record keeps its own date (novox/hq issue 844). + day := r.Date + if r.Pending != nil && !r.IsInsight() { + day = k.now().Format("2006-01-02") + } + refusals = append(refusals, JudgeRecordOn(r, held, k.lookup(ctx), ref, day)...) if raw, _ := json.Marshal(r); len(raw) > MostRecordBytes { refusals = append(refusals, Refusal{Rule: "size", Says: fmt.Sprintf("the record is %d bytes; a record holds at most %d", len(raw), MostRecordBytes)}) } @@ -381,7 +396,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { } return nil, fmt.Errorf("%s is refused by %d rule(s), and nothing was written:\n- %s", key, len(refusals), strings.Join(lines, "\n- ")) } - v := Version{By: caller, At: k.now(), Why: why, Record: r, Insight: !current} + v := Version{By: caller, At: k.now(), Why: why, Record: r, Insight: r.IsInsight()} if err := k.Store.Save(ctx, v, current, false, held.Revision); err != nil { return nil, fmt.Errorf("%s could not be written (%v); nothing was proposed", key, err) } @@ -397,7 +412,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { if r.NeedsOperator { says += ", and then the operator's Approve" } - if !current { + if !current && !r.IsInsight() { + says = fmt.Sprintf("A new version of %s is proposed as version %d. The knowledge base reviews it now; the served version stays "+ + "until that review approves it", key, n) + } else if !current { says = fmt.Sprintf("A progressive insight to %s is proposed as version %d. The knowledge base reviews it now; the accepted "+ "version is served until that review approves it", key, n) } @@ -416,8 +434,11 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran at := k.now() if insight { in := r.Pending.Insight + isInsight := r.IsInsight() r.Pending = nil - r.Insights = append(r.Insights, in) + if isInsight { + r.Insights = append(r.Insights, in) + } r.Status = held.Current.Status if r.Kind == KindResearch && contains(ResearchStatuses, r.Effort) && held.Current.Status != StatusSuperseded { r.Status = r.Effort @@ -442,7 +463,7 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran if warrant != "" { why += "; " + warrant } - if err := k.Store.Save(ctx, Version{By: by, At: at, Why: why, Record: r, Insight: insight}, true, true, held.Revision); err != nil { + if err := k.Store.Save(ctx, Version{By: by, At: at, Why: why, Record: r, Insight: insight && w.IsInsight()}, true, true, held.Revision); err != nil { return nil, fmt.Errorf("%s could not be accepted (%v); it stays proposed", r.Key(), err) } k.emit(EventAccepted, r, by, why, held.Versions+1) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 2c4f1153..2eb6a1ef 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -917,7 +917,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " + "every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " + "link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " + - "proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " + + "proposal is revised by proposing it again; an accepted record (an active research effort excepted, which takes new versions) changes only by a progressive insight (insight: what " + "stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " + "records it links and the rules. A decision that names no home and that no accepted record links is not judged: " + "its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index d9d5b069..46649ab7 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -89,7 +89,8 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { t.Errorf("the manifest invokes %s, which the code never calls", inv) } } - if len(m.Build.Artifacts) != 1 || m.Build.Artifacts[0].From != "cmd/mesh-knowledge" || m.Build.Artifacts[0].Binary != Module || + // The tools bundle, and the archive the build packs files/ into for the templates (novox/hq ADR 0349 §2). + if len(m.Build.Artifacts) != 2 || m.Build.Artifacts[1].From != "files" || m.Build.Artifacts[0].From != "cmd/mesh-knowledge" || m.Build.Artifacts[0].Binary != Module || m.Build.Artifacts[0].Env["DATABASE_URL_FILE"] == "" || m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_SETTINGS"] == "" || m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] == "" || !reflect.DeepEqual(m.Build.Artifacts[0].Loads, []string{Module}) { t.Fatalf("build %+v", m.Build) @@ -121,6 +122,12 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) { !reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) { t.Fatalf("watch %v, tools %v", watch, m.Tools) } + // The watch's verdict is whether the database serves, so its check names the provision it exercises: while the + // database's provider is unhealthy, this finding is held under the provider's condition (hq ADR 0240 rule 5, + // issue 145). + if h["needs"] != "postgres-database" { + t.Fatalf("watch's check needs %v; it exercises postgres-database", h["needs"]) + } if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] { t.Fatal("watch writes another status file than the health tool reads") } @@ -132,7 +139,7 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) { } settings := byID["settings"] var s Settings - if err := json.Unmarshal([]byte(settings["content"].(string)), &s); err != nil || s.JudgeModel == "" || s.ReservedFor != "168h" { + if err := json.Unmarshal([]byte(templateText(t, settings)), &s); err != nil || s.JudgeModel == "" || s.ReservedFor != "168h" { t.Fatalf("the settings the manifest writes are not the module's: %v %+v", err, s) } if d, err := time.ParseDuration(s.ReservedFor); err != nil || d != DefaultReservedFor { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go index b130cc53..5f05e26d 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go @@ -192,7 +192,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string, if err != nil || e == nil { return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err) } - if err := k.Store.Save(ctx, Version{By: ReviewBy, At: k.now(), Why: said, Record: w, Insight: e.Pending != nil}, + if err := k.Store.Save(ctx, Version{By: ReviewBy, At: k.now(), Why: said, Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()}, e.Pending == nil, false, e.Revision); err != nil { return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err) } @@ -208,7 +208,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string, return unasked(fmt.Sprintf("the ask could not be made (%v)", err)) } w.Ask = oa - if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: "reviewed: approved; the operator is asked", Record: w, Insight: held.Pending != nil}, + if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: "reviewed: approved; the operator is asked", Record: w, Insight: held.Pending != nil && held.Pending.IsInsight()}, held.Pending == nil, false, held.Revision); err != nil { return "", fmt.Errorf("%s: the review could not be kept (%v); the operator was not asked", w.Key(), err) } @@ -267,7 +267,7 @@ func (k *KB) Reconcile() ([]string, error) { func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string { w = w.Clone() w.Ask.Said = why - if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: "the operator's ask " + why, Record: w, Insight: e.Pending != nil}, + if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: "the operator's ask " + why, Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()}, e.Pending == nil, false, e.Revision); err != nil { return fmt.Sprintf("%s: the end of its ask could not be kept (%v)", w.Key(), err) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go index f364e5b0..4ad61e16 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go @@ -360,6 +360,10 @@ type PendingState struct { Insight Insight `json:"insight"` } +// IsInsight says the record is waiting as a progressive insight, a correction of fact that says what stood before. +// A pending version without one is a new version of an active research effort (novox/hq issue 844). +func (r *Record) IsInsight() bool { return r.Pending != nil && r.Pending.Insight.Before != "" } + // Key is the record's key. func (r *Record) Key() Key { return Key{r.Kind, r.Number} } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go index 0fe414a2..24fbcf57 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go @@ -111,7 +111,7 @@ var reviewSchema = map[string]any{ func ReviewQuestion(r *Record, linked []*Record) string { var b strings.Builder what := "a proposed " + r.Kind + " record" - if r.Pending != nil { + if r.IsInsight() { what = "a correction of fact (a progressive insight) proposed to an accepted " + r.Kind + " record; what stood before: " + r.Pending.Insight.Before } fmt.Fprintf(&b, "You review %s of the mesh's knowledge base, %s. You see only the record, the records it links, and the "+ @@ -475,7 +475,7 @@ func (k *KB) reviewOne(key Key) (string, error) { version = now.PendingVersion } save := func(r *Record, why string) error { - return k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r, Insight: now.Pending != nil}, + return k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r, Insight: now.Pending != nil && now.Pending.IsInsight()}, now.Pending == nil, false, now.Revision) } if err != nil { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index 3f657d63..fbacf6c0 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -123,9 +123,16 @@ func allText(r *Record) string { // JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the // proposal being revised, or the accepted record an insight corrects); lookup reads every other record. func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal { + return JudgeRecordOn(r, held, lookup, ref, r.Date) +} + +// JudgeRecordOn is JudgeRecord with the day the dated rules are held from: the record's own day for a new +// record, the day of the proposal for a new version of an active research effort (novox/hq issue 844). The rules +// that read the record's date (the date and insight-form rules) keep reading r.Date. +func JudgeRecordOn(r *Record, held *Entry, lookup Lookup, ref *Reference, day string) []Refusal { var out []Refusal refuse := func(rule, format string, a ...any) { - if Holds(rule, r.Kind, r.Date) { + if Holds(rule, r.Kind, day) { out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)}) } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/templates_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/mesh-knowledge/files/database.url b/modules/mesh-knowledge/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/modules/mesh-knowledge/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-knowledge/files/settings.json b/modules/mesh-knowledge/files/settings.json new file mode 100644 index 00000000..6f682fed --- /dev/null +++ b/modules/mesh-knowledge/files/settings.json @@ -0,0 +1,5 @@ +{ + "judge-model": "sonnet", + "drafting": "on", + "reserved-for": "168h" +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index dbb08b00..261662be 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -77,6 +77,24 @@ "records.records_read", "seat:operator-channel.notify" ], + "config": { + "judge-model": { + "type": "string", + "description": "The model the knowledge base's drafting and confirming judgements ask, by the agent's name for it, such as sonnet; empty is sonnet.", + "default": "sonnet" + }, + "drafting": { + "type": "string", + "description": "Whether the knowledge base drafts and confirms clauses itself: off stops it, anything else is on.", + "default": "on" + }, + "reserved-for": { + "type": "string", + "description": "How long a reserved number waits for its proposal before it is withdrawn, as a duration such as 168h; empty is the knowledge base's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "default": "168h" + } + }, "resources": [ { "id": "state", @@ -89,7 +107,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "settings", @@ -97,8 +115,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["judge-model", "drafting", "reserved-for"], "merge": "json", - "content": "{\n \"judge-model\": \"sonnet\",\n \"drafting\": \"on\",\n \"reserved-for\": \"168h\"\n}\n" + "template": "files/settings.json" }, { "id": "prepare", @@ -156,6 +175,7 @@ "health": { "kind": "tool", "tool": "knowledge_health", + "needs": "postgres-database", "interval": "60s", "timeout": "10s", "looks": 2, @@ -180,6 +200,11 @@ "MESH_KNOWLEDGE_SETTINGS": "${dir:state}/settings.json", "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mesh-mail-notifier/README.md b/modules/mesh-mail-notifier/README.md new file mode 100644 index 00000000..9891e96b --- /dev/null +++ b/modules/mesh-mail-notifier/README.md @@ -0,0 +1,40 @@ +# mesh-mail-notifier — the default holder of mail-notifier + +Built from mesh-mail's source (novox/hq ADR 0378 §9, as amended by ADR 0379, issue 800 part B): it carries a +byte-identical copy of mesh-mail's event shape (`mailevent.go`, pinned by `copies_test.go`, the way +`modules/networkmanager`'s `uplink-tools` carries `ifwatchd`'s own file, ADR 0241). Nothing here names +Betterbird (ADR 0379 §3), and nothing here reaches a mailbox: it only hears what mesh-mail already said. + +## What it does + +It claims `mail-notifier` (held once per machine, needing `node-notifier` there) and shows each new inbox mail +on this machine's notifier, through `node-notifier.send`, at most once: + +- quiet during a first sync or a resync (mesh-mail already says nothing then, `imap.go` ~387) +- quiet for a mail from the account's own address (its address, from `mail-store.accounts`; not known, the + notice is shown) +- quiet for a mail already marked read (`\Seen` in its flags) +- quiet for anything outside the inbox +- of one account's qualifying mail within a sliding minute, the first three are shown one by one; the rest of + that minute's mail is folded into one notice naming the count and the latest sender, rather than shown one + by one + +A notice `node-notifier.send` refuses stays pending and is tried again, at most once a minute, without holding +up any other account's notice; nothing is deleted before it is shown. Each notice shown emits this module's +own event `shown` (account and message id, never sender or subject), and is kept in its own state (`shown`) so +a redelivered event is never counted or shown twice — by the mail's message id wherever it has one, so a +UIDVALIDITY reset cannot make an already-shown mail look new. + +## Where it runs + +**Its own account** (`runs-as`), because it hears mesh-mail's restricted stream `MAIL` (novox/hq ADR 0376 §2): +the machine's runtime runs as the operator's account, which every agent shares and which never hears a +restricted stream. Held once per machine (wherever `mail-notifier` is assigned); the operator grants it the +accounts it announces, in mesh-mail's trusted setting `grants` (`mail-store`'s stream). + +## Tools + +| tool | does | +|---|---| +| `mail-notifier.status` | every account it is granted, when it last showed each one a notice, and any notice still failing to send (its count, last error, since when). Read-only | +| `mail_notifier_health` | how it is: unhealthy once a notice has failed to show for more than ten minutes, healthy otherwise, even mid-retry. Not (yet) the node-engine's own check — this is one process, judged only by its own tool (ADR 0227 rule 8); a watching process of its own is issue 841, not yet decided. Read-only | diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go new file mode 100644 index 00000000..eb4ff641 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go @@ -0,0 +1,31 @@ +package main + +// mesh-mail-notifier carries a byte-identical copy of mesh-mail's event shape (novox/hq ADR 0378 §9, issue 800 +// part B), the way modules/networkmanager's uplink-tools carries ifwatchd's own file (ADR 0241, copies_test.go): +// each builds alone, so each holds its own copy, and this test holds them to one text. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// source is where mesh-mail's own copy of the file lives. +var source = filepath.Join("..", "..", "..", "mesh-mail", "cmd", "mesh-mail") + +func TestCarriesTheSameMailEventAsMeshMail(t *testing.T) { + for _, f := range []string{"mailevent.go"} { + mine, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + theirs, err := os.ReadFile(filepath.Join(source, f)) + if err != nil { + t.Fatalf("mesh-mail's copy of %s could not be read: %v", f, err) + } + if !bytes.Equal(mine, theirs) { + t.Errorf("this copy of %s differs from mesh-mail's: change both together", f) + } + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go new file mode 100644 index 00000000..1ace5050 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go @@ -0,0 +1,62 @@ +package main + +// mesh-mail-notifier's own health (review round 1 of issue 800, which added this tool beside mail-notifier's +// seat verb status, as mesh-mail's own health tool is mail_health beside its seat tools). A notice that fails +// to send stays pending and is retried, at most once a minute (Flush, notifier.go); the account's +// failing-since stays in the Store, so this tool — its own process, asked separately by the node-engine or by +// mesh_call — sees the same account the live notifier does, with no state of its own to lose between asks. +// +// Unhealthy once an account's notice has failed to show for more than UnhealthyAfter; healthy otherwise, even +// while a notice is mid-retry. +// +// STOP (review round 1 of issue 800, not decided by its note): this tool is served and tested, but module.json +// does not name it as the notify process's own health check — mesh-mail-notifier has one process (notify), and +// the catalogue's gate refuses a module judged only by its own tool, with no check of another kind beside it +// (ADR 0227 rule 8, ADR 0240 rule 2; internal/catalogue/health.go ~297 in mesh-controller). mesh-mail avoids +// this because it has two processes — keep (kind unit) beside watch (kind tool, mail_health); mesh-mail-notifier +// has only notify, so wiring this tool the same way trips the gate. notify's health stays kind unit (liveness, +// as it was before this round) until the lead decides how a one-process module gets an automatic, node-engine +// enforced health of this kind: a second process judged some other way, or a change to the gate's rule. + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// HealthTool is the module's own health tool the node-engine asks (beside mail-notifier.status, the seat's own verb). +const HealthTool = "mail_notifier_health" + +// UnhealthyAfter is how long a notice may fail to send before that is unhealthy, not merely retrying. +const UnhealthyAfter = 10 * time.Minute + +// Judge is mesh-mail-notifier's health, from the failures the Store keeps. +func Judge(failures map[string]FailureStatus, now time.Time) health.Answer { + if len(failures) == 0 { + return health.Healthy("every notice shown; none pending a retry") + } + accounts := make([]string, 0, len(failures)) + for account := range failures { + accounts = append(accounts, account) + } + sort.Strings(accounts) + + var problems, retrying []string + for _, account := range accounts { + f := failures[account] + age := now.Sub(f.Since).Round(time.Second) + if now.Sub(f.Since) > UnhealthyAfter { + problems = append(problems, fmt.Sprintf("%s: its notice has failed to show for %s (%d attempt(s), last: %s)", + account, age, f.Count, f.LastError)) + } else { + retrying = append(retrying, fmt.Sprintf("%s has been retrying for %s", account, age)) + } + } + if len(problems) > 0 { + return health.Unhealthy(strings.Join(problems, "; ")) + } + return health.Healthy(strings.Join(retrying, "; ")) +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go new file mode 100644 index 00000000..750beae2 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +func TestJudgeIsUnhealthyOnlyPastUnhealthyAfter(t *testing.T) { + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + + if a := Judge(nil, now); a.State != health.StateHealthy { + t.Fatalf("no failures: %+v, want healthy", a) + } + + recent := map[string]FailureStatus{"work": {Count: 1, LastError: "no answer", Since: now.Add(-2 * time.Minute)}} + if a := Judge(recent, now); a.State != health.StateHealthy { + t.Fatalf("a failure 2m old: %+v, want healthy (still retrying)", a) + } + + stale := map[string]FailureStatus{"work": {Count: 11, LastError: "no answer", Since: now.Add(-11 * time.Minute)}} + a := Judge(stale, now) + if a.State != health.StateUnhealthy { + t.Fatalf("a failure 11m old: %+v, want unhealthy (past UnhealthyAfter)", a) + } + if !strings.Contains(a.Why, "work") || !strings.Contains(a.Why, "no answer") { + t.Errorf("Why = %q, want it to name the account and the last error", a.Why) + } + + mixed := map[string]FailureStatus{ + "work": {Count: 11, LastError: "no answer", Since: now.Add(-11 * time.Minute)}, + "home": {Count: 1, LastError: "no answer", Since: now.Add(-time.Minute)}, + } + if a := Judge(mixed, now); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "work") { + t.Errorf("one stale failure beside one fresh one: %+v, want unhealthy naming work", a) + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go new file mode 100644 index 00000000..fb9596fb --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go @@ -0,0 +1,13 @@ +package main + +// The shape of a mail-store message event (novox/hq ADR 0378 §2, issue 800 part A), moved here on its own so +// mesh-mail-notifier (issue 800 part B) carries a byte-identical copy, the way modules/networkmanager's +// uplink-tools carries ifwatchd's own file (novox/hq ADR 0241, copies_test.go). Nothing here reaches a mailbox or +// a server; it only says where an account's change is told on the bus and what the three changes are called. + +// EventKey is where an account's change is said: mail..message. (research 049 +// document 02 §5), the account by its name in the setting, never its address. +func EventKey(account, change string) string { return "mail." + account + ".message." + change } + +// Changes are the three things said of a message. +var Changes = []string{"added", "changed", "removed"} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go new file mode 100644 index 00000000..1e37f663 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go @@ -0,0 +1,232 @@ +// mesh-mail-notifier: the default claimant of mail-notifier, built from mesh-mail's own event shape (novox/hq +// ADR 0378 §9, issue 800 part B). A Go bundle the node's runtime launches, its own account (runs-as, ADR 0376 +// §2): the one process serves mail-notifier.status and the module's own health tool (mail_notifier_health, +// review round 1 of issue 800), and, beside stdio.Serve, hears mesh-mail's added events and shows them +// (exactly as modules/mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve) — not +// a separate `notify` process, which systemd gave an empty stdin and so exited at once, every five seconds +// (novox/hq issue 859). What this module says, it says on stderr; stdout is the MCP channel. +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// AddedPattern is the mesh-mail stream's event this module consumes (its own `consumes` entry, novox/hq ADR +// 0363, ADR 0378 §9): a new, changed or removed message's `added` only. +const AddedPattern = "mesh-mail.mail.*.message.added" + +// StatusTool is the mail-notifier seat's one verb this module serves. +const StatusTool = "mail-notifier.status" + +// ShownEvent is this module's own event (its `emits`), one per notice shown. +const ShownEvent = "shown" + +func main() { + n := &Notifier{ + Send: func(args map[string]any) error { + _, err := stdio.Ask(sendKey(), args) + return err + }, + Accounts: readAccounts, + Store: busStore{}, + EmitShown: func(account, messageID string) error { + return stdio.Emit(ShownEvent, map[string]any{"account": account, "message-id": messageID}) + }, + Logf: logf, + } + n.init() + + tools := []stdio.Tool{ + {Name: StatusTool, + Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " + + "notice of new mail (novox/hq ADR 0378 §7). Read-only.", + Run: func(map[string]any) (any, error) { return n.Status() }}, + {Name: HealthTool, + Description: "How mesh-mail-notifier is: unhealthy once a notice has failed to show for more than " + + "ten minutes, healthy otherwise, even mid-retry (review round 1 of issue 800). Read-only.", + Run: func(map[string]any) (any, error) { + failures, err := busStore{}.Failures() + if err != nil { + return nil, err + } + return Judge(failures, time.Now()), nil + }}, + } + if err := run(n, watch, os.Stdin, os.Stdout, tools); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// sendKey is the ask key Send uses: this machine's own node-notifier, named exactly as desk-channel asks +// another machine's ("seat:node-notifier.send@"+machine) and as the SDK's own Emit reads this machine's name +// (MESH_NODE, which the runtime sets) — not the bare seat, which any machine's node-notifier may answer +// first, leaving this one's notices undelivered (novox/hq issue 865). +func sendKey() string { return "seat:node-notifier.send@" + os.Getenv("MESH_NODE") } + +// run is main's startup path, factored so a test can run it: the watcher started before stdio.Serve, exactly +// as mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve — so the watcher keeps +// running whatever stdio.Serve does with the stream it is given, including returning at once when stdin is +// already at EOF, as systemd gave the old separate `notify` process (novox/hq issue 859). The node's runtime +// launches this one process and keeps it alive. +func run(n *Notifier, watcher func(*Notifier), in io.Reader, out io.Writer, tools []stdio.Tool) error { + go watcher(n) + return stdio.ServeOn("", tools, in, out) +} + +// watch hears mesh-mail's added events and, once a second, flushes what Notifier.Flush finds ready: an +// account's first mails of its window at once, and its folded overflow once the window is over (ADR 0378 §9, +// the lead's reading, review round 1 of issue 800). +func watch(n *Notifier) { watchWith(n, stdio.Subscribe, time.Sleep) } + +// watchWith is watch with its subscribe and sleep factored out, so a test can make a subscribe fail without +// the real bus and without the real wait. A subscribe the runtime refuses is retried, 2 seconds doubling to +// at most a minute, logging each refusal — exactly as modules/mesh-knowledge's hear does — never fatal: a +// refusal here must never take mail-notifier.status and mail_notifier_health down with it (novox/hq issue +// 859, review round 1; the old os.Exit(1) did exactly that). +func watchWith(n *Notifier, subscribe func(string, func(stdio.Envelope) error) error, sleep func(time.Duration)) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + sleep(wait) + err := subscribe(AddedPattern, func(e stdio.Envelope) error { + var body EventBody + if err := json.Unmarshal(e.Body, &body); err != nil { + logf("[mesh-mail-notifier] an added event could not be read; taken and dropped: %v", err) + return nil + } + return n.Add(body) + }) + if err == nil { + break + } + logf("[mesh-mail-notifier] does not hear %s yet (%v); asking again in %s", AddedPattern, err, wait) + } + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := n.Flush(); err != nil { + logf("[mesh-mail-notifier] a buffered notice could not be shown: %v", err) + } + } +} + +// readAccounts asks mail-store.accounts for the accounts this module is granted, by name to own address. +func readAccounts() (map[string]string, error) { + raw, err := stdio.Ask("seat:mail-store.accounts", map[string]any{}) + if err != nil { + return nil, err + } + var answer struct { + Accounts []struct { + Account string `json:"account"` + Address string `json:"address"` + } `json:"accounts"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, err + } + out := map[string]string{} + for _, a := range answer.Accounts { + out[a.Account] = a.Address + } + return out, nil +} + +// busStore keeps the notifier's own state (the `shown` bucket the manifest declares): which messages were +// shown already, each account's last notice, and each account's notice still failing to send. +type busStore struct{} + +func stateKey(parts ...string) string { + words := make([]string, len(parts)) + for i, p := range parts { + var b strings.Builder + for _, r := range p { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + words[i] = b.String() + } + return strings.Join(words, "_") +} + +func (busStore) HandleSeen(handle string) (bool, error) { + e, err := stdio.State("shown").Get("handle_" + stateKey(strings.Split(handle, "\x1f")...)) + return e != nil, err +} + +func (busStore) MarkHandle(handle string) error { + _, err := stdio.State("shown").Put("handle_"+stateKey(strings.Split(handle, "\x1f")...), true) + return err +} + +func (busStore) PutStatus(account string, s AccountStatus) error { + _, err := stdio.State("shown").Put("status_"+stateKey(account), s) + return err +} + +func (busStore) Statuses() (map[string]AccountStatus, error) { + keys, err := stdio.State("shown").Keys() + if err != nil { + return nil, err + } + out := map[string]AccountStatus{} + for _, k := range keys { + account, ok := strings.CutPrefix(k, "status_") + if !ok { + continue + } + e, err := stdio.State("shown").Get(k) + if err != nil || e == nil { + continue + } + var s AccountStatus + if json.Unmarshal(e.Value, &s) == nil { + out[account] = s + } + } + return out, nil +} + +func (busStore) PutFailure(account string, f FailureStatus) error { + _, err := stdio.State("shown").Put("failure_"+stateKey(account), f) + return err +} + +func (busStore) ClearFailure(account string) error { + return stdio.State("shown").Delete("failure_" + stateKey(account)) +} + +func (busStore) Failures() (map[string]FailureStatus, error) { + keys, err := stdio.State("shown").Keys() + if err != nil { + return nil, err + } + out := map[string]FailureStatus{} + for _, k := range keys { + account, ok := strings.CutPrefix(k, "failure_") + if !ok { + continue + } + e, err := stdio.State("shown").Get(k) + if err != nil || e == nil { + continue + } + var f FailureStatus + if json.Unmarshal(e.Value, &f) == nil { + out[account] = f + } + } + return out, nil +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/manifest_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/manifest_test.go new file mode 100644 index 00000000..2512db68 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/manifest_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The manifest's own shape (novox/hq issue 859): the watch loop runs in the base invocation beside +// stdio.Serve, so no resource declares a separate `notify` process any more — the thing that exited at once, +// every five seconds, because a systemd-run process's stdin is empty and stdio.Serve returns at EOF. + +type manifestResources struct { + Resources []map[string]any `json:"resources"` +} + +func readManifestResources(t *testing.T) manifestResources { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifestResources + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func TestNoProcessResourceIsDeclared(t *testing.T) { + m := readManifestResources(t) + for _, r := range m.Resources { + if r["type"] == "process" { + t.Fatalf("module.json still declares a process resource (%v): the watcher runs in the base invocation now, not a separate process (novox/hq issue 859)", r) + } + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go new file mode 100644 index 00000000..6f8f997d --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go @@ -0,0 +1,537 @@ +package main + +// mesh-mail-notifier: the default holder of mail-notifier (novox/hq ADR 0378 §7, as amended by ADR 0379 §4, +// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each +// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says +// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen), +// and for anything outside the inbox. For each new qualifying mail of an account, arriving at t, N counts that +// account's qualifying mail — shown or folded alike, each counted once — that arrived in the 60 seconds up to +// and including t: N <= FoldOver is shown at once; N > FoldOver is folded. The first folded mail opens the +// account's fold; every folded mail joins it; the fold is shown, as one notice naming how many it holds and +// the latest sender, once Window has passed since its own first mail — after which a new fold may open. The +// count is a true sliding window of arrivals, not a per-session quota that only resets after a quiet Window: +// review round 2's reading kept a session's count alive, and so folding, for as long as any gap stayed under +// Window, which folds a steady trickle (one mail every 50s, say) forever even though no burst ever happened; +// review round 3 of issue 800 replaces that reading with this one. Nothing pending — a folded mail awaiting +// its fold's deadline, or a failed send awaiting its retry — is ever dropped (novox/hq ADR 0378 §9, review +// round 3 of issue 800, which replaces the round 1 and round 2 readings). +// +// Nothing here names Betterbird (ADR 0379 §3). + +import ( + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Inbox is the one folder a new mail is shown for. +const Inbox = "INBOX" + +// Seen is the flag that means a mail was already read: shown for nothing it marks. +const SeenFlag = "\\Seen" + +// FoldOver is how many new mails from one account, within one window, are still shown one by one; the rest of +// that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three"). +const FoldOver = 3 + +// Window is the trailing 60 seconds ADR 0378 §9 counts a mail's N over (every qualifying mail of the account, +// shown or folded alike, that arrived in the 60 seconds up to and including this one), and the time a fold +// waits, from its own first mail, before it is shown (review round 3 of issue 800, which replaces the round 1 +// and round 2 readings: the count is a true sliding window of arrivals, not a per-session quota that stays +// alive, and so keeps folding, for as long as any gap between mails stays under Window). +const Window = time.Minute + +// RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that +// account again: at most once a minute (review round 1 of issue 800), so a send that keeps failing is not +// retried on every Flush. +const RetryBackoff = time.Minute + +// AccountsCacheFor is how long mail-store.accounts's answer is reused before it is asked again (review round +// 1 of issue 800, bullet 5): neither the quiet rule nor status asks it more than once a minute. +const AccountsCacheFor = time.Minute + +// Person is a sender or a recipient, as mesh-mail's event says it. +type Person struct { + Name string `json:"name,omitempty"` + Address string `json:"address"` +} + +// EventBody is what mesh-mail's added event says of a message (metadata only; mailevent.go names where it is +// said). Unknown fields are ignored: the notifier reads only what it needs. +type EventBody struct { + Account string `json:"account"` + Folder string `json:"folder"` + UID uint32 `json:"uid"` + UIDValidity uint32 `json:"uidvalidity"` + MessageID string `json:"message-id"` + Subject string `json:"subject"` + From []Person `json:"from"` + Flags []string `json:"flags"` +} + +// Handle names one message, stably, for the dedupe state (novox/hq ADR 0378 §9: "a redelivered event is not +// shown twice"). The message id is the key wherever a message carries one, so a UIDVALIDITY reset — which +// changes every UID in a folder — can never make an already-shown mail look new, or a genuinely new one look +// already shown; only a message with no message id falls back to its folder, UIDVALIDITY and UID (review +// round 1 of issue 800). +func (e EventBody) Handle() string { + if e.MessageID != "" { + return e.Account + "\x1f" + e.MessageID + } + return e.Account + "\x1f" + e.Folder + "\x1f" + + strconv.FormatUint(uint64(e.UIDValidity), 10) + "\x1f" + strconv.FormatUint(uint64(e.UID), 10) +} + +func (e EventBody) hasFlag(flag string) bool { + for _, f := range e.Flags { + if strings.EqualFold(f, flag) { + return true + } + } + return false +} + +// sender is how a message's first sender reads in a notice: its name and address, or its address alone. +func (e EventBody) sender() string { + if len(e.From) == 0 { + return "(unknown sender)" + } + p := e.From[0] + if p.Name == "" { + return p.Address + } + return p.Name + " <" + p.Address + ">" +} + +func (e EventBody) fromAddress() string { + if len(e.From) == 0 { + return "" + } + return e.From[0].Address +} + +// Notice is one notification the notifier shows: its args for node-notifier.send, and the message its `shown` +// event names (the latest one, for a folded notice). +type Notice struct { + Account string + Args map[string]any + Handles []string // every message this notice accounts for, marked seen once shown + Latest EventBody +} + +// FailureStatus is what the notifier knows of an account's notice still failing to send (review round 1 of +// issue 800): how many attempts, the last error, and since when. Kept in the Store, not only in memory, so +// the health tool — its own process, asked separately by the node-engine — sees the same account the live +// notifier does. +type FailureStatus struct { + Count int `json:"count"` + LastError string `json:"last-error"` + Since time.Time `json:"since"` +} + +// Store is what the notifier keeps of its own (the `shown` state the manifest declares): whether a message +// was shown already, each account's last notice, and each account's notice still failing to send. +type Store interface { + HandleSeen(handle string) (bool, error) + MarkHandle(handle string) error + PutStatus(account string, s AccountStatus) error + Statuses() (map[string]AccountStatus, error) + PutFailure(account string, f FailureStatus) error + ClearFailure(account string) error + Failures() (map[string]FailureStatus, error) +} + +// AccountStatus is what the notifier last showed of one account. +type AccountStatus struct { + At time.Time `json:"at"` + Summary string `json:"summary"` +} + +// accountBuffer is one account's state: history is every qualifying mail's arrival time, kept only for the +// trailing Window, that N is counted against (review round 3 of issue 800); immediate holds mail waiting to be +// shown one by one at the next Flush; folded holds the account's current fold (its first FoldOver mails within +// Window are never folded, so a fold only ever holds the overflow from the fourth qualifying mail of its own +// window onward), due as one notice once Window has passed since foldStart, its first mail's arrival. +type accountBuffer struct { + history []time.Time + foldStart time.Time // when the current fold's first mail arrived; zero while nothing is folded + immediate []EventBody + folded []EventBody +} + +// Notifier buffers qualifying mail per account and shows it: the first FoldOver mails of a window one by one, +// the rest folded once the window ends. A notice that fails to send is tried again, at most once a minute, +// without holding up any other account's notice. +type Notifier struct { + // Send calls node-notifier.send on this machine. + Send func(args map[string]any) error + // Accounts reads the accounts the notifier is granted, by mail-store.accounts: each one's own address, for + // the quiet rule (novox/hq ADR 0378 §9's LEAD note). An account not known there is shown anyway. + Accounts func() (map[string]string, error) + Store Store + // EmitShown says mesh-mail-notifier's own event `shown`: account and message id, never sender or subject. + EmitShown func(account, messageID string) error + Now func() time.Time + Logf func(string, ...any) + + mu sync.Mutex + pending map[string]*accountBuffer + buffered map[string]bool // a message's Handle while it is waiting to be shown: never counted twice + cooldown map[string]time.Time // account -> not retried before this time, after a failed send + + accountsAt time.Time + accountsVal map[string]string + accountsErr error +} + +func (n *Notifier) init() { + n.mu.Lock() + defer n.mu.Unlock() + if n.pending == nil { + n.pending = map[string]*accountBuffer{} + n.buffered = map[string]bool{} + n.cooldown = map[string]time.Time{} + } + if n.Now == nil { + n.Now = time.Now + } + if n.Logf == nil { + n.Logf = func(string, ...any) {} + } +} + +// Add takes one added event. It returns an error only to have the event offered again (the handler +// Subscribe wraps this in follows that rule). +func (n *Notifier) Add(e EventBody) error { + n.init() + handle := e.Handle() + seen, err := n.Store.HandleSeen(handle) + if err != nil { + return err + } + if seen { + return nil // a redelivery of a message already shown (or already judged not to be) + } + own, known, err := n.ownAddress(e.Account) + if err != nil { + return err + } + if !n.qualifies(e, own, known) { + return nil + } + + n.mu.Lock() + defer n.mu.Unlock() + if n.buffered[handle] { + return nil // a redelivery of a message still waiting to be shown: never counted twice + } + now := n.Now() + b := n.pending[e.Account] + if b == nil { + b = &accountBuffer{} + n.pending[e.Account] = b + } + // N is every qualifying mail of this account — shown or folded alike, each counted once — that arrived in + // the 60 seconds up to and including now: a true sliding window over arrivals, not a per-session quota that + // stays alive, and so keeps folding, for as long as any gap stays under Window (review round 3 of issue + // 800, which replaces the round 1 and round 2 readings; nothing in this count depends on whether an earlier + // mail has been shown yet). + cutoff := now.Add(-Window) + history := make([]time.Time, 0, len(b.history)+1) + for _, at := range b.history { + if !at.Before(cutoff) { + history = append(history, at) + } + } + b.history = append(history, now) + n.buffered[handle] = true + if len(b.history) <= FoldOver { + b.immediate = append(b.immediate, e) + } else { + if len(b.folded) == 0 { + b.foldStart = now + } + b.folded = append(b.folded, e) + } + return nil +} + +// qualifies says whether a mail is shown at all: a new inbox mail, not the account's own, not already read. +func (n *Notifier) qualifies(e EventBody, ownAddress string, ownKnown bool) bool { + if !strings.EqualFold(e.Folder, Inbox) { + return false + } + if e.hasFlag(SeenFlag) { + return false + } + if ownKnown && ownAddress != "" && strings.EqualFold(e.fromAddress(), ownAddress) { + return false + } + return true +} + +func (n *Notifier) ownAddress(account string) (string, bool, error) { + accounts, err := n.readAccounts() + if err != nil { + // Not known: ADR 0378 §9's LEAD note says the notice is shown when the address cannot be read. Logged, + // not retried: mail-store being unreachable is not this event's fault. + n.Logf("[mesh-mail-notifier] the account's own address could not be read (%v); the notice is shown", err) + return "", false, nil + } + addr, ok := accounts[account] + return addr, ok, nil +} + +// readAccounts asks mail-store.accounts, reusing its last answer for AccountsCacheFor so neither the quiet +// rule nor status reads it more than once a minute (review round 1 of issue 800, bullet 5). +func (n *Notifier) readAccounts() (map[string]string, error) { + n.mu.Lock() + if n.accountsVal != nil && n.Now().Sub(n.accountsAt) < AccountsCacheFor { + val, err := n.accountsVal, n.accountsErr + n.mu.Unlock() + return val, err + } + n.mu.Unlock() + + val, err := n.Accounts() + n.mu.Lock() + n.accountsVal, n.accountsErr, n.accountsAt = val, err, n.Now() + n.mu.Unlock() + return val, err +} + +// Flush shows what is ready: each account's first FoldOver mails of its window, one by one, as soon as Flush +// runs, and the window's folded overflow once the window has run its minute. Accounts are judged in a sorted +// order, not a map's iteration order, so the tests do not depend on it (review round 1 of issue 800, BLOCKING +// 1). A notice that fails to send leaves its mail pending, tried again once RetryBackoff has passed, and never +// holds up another account's notice (BLOCKING 2): nothing is deleted before it is shown. +func (n *Notifier) Flush() error { + n.init() + now := n.Now() + + n.mu.Lock() + accounts := make([]string, 0, len(n.pending)) + for account := range n.pending { + accounts = append(accounts, account) + } + n.mu.Unlock() + sort.Strings(accounts) + + for _, account := range accounts { + n.flushAccount(account, now) + } + return nil +} + +// flushAccount ships one account's ready notices (its immediate mail, and its fold once Window has passed +// since the fold's own first mail), and keeps whatever fails to send pending, under cooldown, for the next +// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing pending is ever lost +// (review round 2 BLOCKING, kept under review round 3's rule; see Add). +func (n *Notifier) flushAccount(account string, now time.Time) { + n.mu.Lock() + if cd, ok := n.cooldown[account]; ok && now.Before(cd) { + n.mu.Unlock() + return // backing off after a failed send; tried again once RetryBackoff has passed + } + b := n.pending[account] + if b == nil { + n.mu.Unlock() + return + } + immediate := append([]EventBody(nil), b.immediate...) + // The fold is due Window after its own first mail (review round 3 of issue 800). + foldDue := len(b.folded) > 0 && now.Sub(b.foldStart) >= Window + var fold []EventBody + if foldDue { + fold = append([]EventBody(nil), b.folded...) + } + n.mu.Unlock() + + if len(immediate) == 0 && len(fold) == 0 { + return + } + + sentImmediate := 0 + var failErr error + for _, e := range immediate { + if err := n.show(n.singleNotice(account, e)); err != nil { + failErr = err + break + } + sentImmediate++ + } + foldSent := false + if failErr == nil && len(fold) > 0 { + if err := n.show(n.foldNotice(account, fold, len(fold))); err != nil { + failErr = err + } else { + foldSent = true + } + } + + n.mu.Lock() + if cur := n.pending[account]; cur == b { + cur.immediate = cur.immediate[sentImmediate:] + if foldSent { + cur.folded = cur.folded[len(fold):] + if len(cur.folded) == 0 { + cur.foldStart = time.Time{} // nothing left folded: a new fold may open (review round 3 of issue 800) + } else { + // More mail folded in while this batch was being sent: its own Window starts now, counted from + // when it was first noticed. Nothing of it is lost either way. + cur.foldStart = now + } + } + } + if failErr != nil { + n.cooldown[account] = now.Add(RetryBackoff) + n.recordFailureLocked(account, failErr, now) + } else { + delete(n.cooldown, account) + n.clearFailureLocked(account) + } + n.mu.Unlock() +} + +// recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the +// Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and +// the health tool agree on how long it has been failing. +func (n *Notifier) recordFailureLocked(account string, sendErr error, now time.Time) { + n.mu.Unlock() + defer n.mu.Lock() + since := now + count := 1 + if prev, err := n.Store.Failures(); err == nil { + if p, ok := prev[account]; ok { + since = p.Since + count = p.Count + 1 + } + } + if err := n.Store.PutFailure(account, FailureStatus{Count: count, LastError: sendErr.Error(), Since: since}); err != nil { + n.Logf("[mesh-mail-notifier] a failure could not be kept (%v): the health tool may not see it", err) + } +} + +func (n *Notifier) clearFailureLocked(account string) { + n.mu.Unlock() + defer n.mu.Lock() + if err := n.Store.ClearFailure(account); err != nil { + n.Logf("[mesh-mail-notifier] a cleared failure could not be kept cleared: %v", err) + } +} + +// singleNotice is one mail shown on its own: one of an account's first FoldOver mails of its window. +func (n *Notifier) singleNotice(account string, e EventBody) Notice { + return Notice{ + Account: account, + Args: map[string]any{ + "summary": e.Subject, + "body": e.sender() + " — " + account, + "app_name": "mesh-mail", + }, + Handles: []string{e.Handle()}, + Latest: e, + } +} + +// foldNotice is one notice for everything the account's current fold holds — its qualifying mail from the +// fourth within some trailing 60 seconds onward — naming how many mail that fold holds (not the account's +// whole history of arrivals) and the latest sender (novox/hq ADR 0378 §9, review round 3 of issue 800, which +// replaces the round 1 and round 2 readings: " more new mail(s) in , latest from ", "mail" for one and "mails" for more). +func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice { + latest := events[len(events)-1] + notice := Notice{ + Account: account, + Args: map[string]any{ + "summary": strconv.Itoa(count) + " more new " + mailWord(count) + " in " + account, + "body": "latest from " + latest.sender(), + "app_name": "mesh-mail", + }, + Latest: latest, + } + for _, e := range events { + notice.Handles = append(notice.Handles, e.Handle()) + } + return notice +} + +// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3; +// kept by review round 3). +func mailWord(count int) string { + if count == 1 { + return "mail" + } + return "mails" +} + +// show sends one notice, marks every message it accounts for seen, keeps the account's status, and emits +// `shown`. Nothing is marked, kept or emitted when Send fails: the caller keeps the notice pending instead. +func (n *Notifier) show(notice Notice) error { + if err := n.Send(notice.Args); err != nil { + n.Logf("[mesh-mail-notifier] %s's notice could not be shown: %v", notice.Account, err) + return err + } + n.mu.Lock() + for _, h := range notice.Handles { + delete(n.buffered, h) + } + n.mu.Unlock() + for _, h := range notice.Handles { + if err := n.Store.MarkHandle(h); err != nil { + n.Logf("[mesh-mail-notifier] shown, but not kept as shown (%v): a redelivery may show it again", err) + } + } + summary, _ := notice.Args["summary"].(string) + if err := n.Store.PutStatus(notice.Account, AccountStatus{At: n.Now(), Summary: summary}); err != nil { + n.Logf("[mesh-mail-notifier] shown, but the status could not be kept: %v", err) + } + return n.EmitShown(notice.Account, notice.Latest.MessageID) +} + +// Status is the notifier's account of itself, for mail-notifier.status (novox/hq ADR 0378 §7: "accounts it +// announces there, when it last showed a notice"; review round 1 of issue 800: the accounts +// mail-store.accounts gives it, and the failures it is retrying). +func (n *Notifier) Status() (map[string]any, error) { + statuses, err := n.Store.Statuses() + if err != nil { + return nil, err + } + failures, err := n.Store.Failures() + if err != nil { + return nil, err + } + known, knownErr := n.readAccounts() + accounts := map[string]any{} + for account := range known { + accounts[account] = map[string]any{} + } + for account, s := range statuses { + accounts[account] = map[string]any{"at": s.At.UTC().Format(time.RFC3339), "shown": s.Summary} + } + out := map[string]any{ + "accounts": accounts, + "rules": []string{ + "it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)", + "quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox", + "of one account's qualifying mail, one arriving when fewer than three of the account's mails fell in the 60 seconds before it is shown at once; the rest is folded and shown, as one notice naming how many it holds and the latest sender, 60 seconds after the fold's own first mail", + }, + } + if knownErr != nil { + out["accounts-error"] = knownErr.Error() + } + if len(failures) > 0 { + failing := map[string]any{} + for account, f := range failures { + failing[account] = map[string]any{ + "count": f.Count, + "last-error": f.LastError, + "since": f.Since.UTC().Format(time.RFC3339), + } + } + out["failing"] = failing + } + return out, nil +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go new file mode 100644 index 00000000..3592c95b --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go @@ -0,0 +1,569 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +// fakeStore is Store kept in memory, for the test alone. +type fakeStore struct { + mu sync.Mutex + handles map[string]bool + statuses map[string]AccountStatus + failures map[string]FailureStatus +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + handles: map[string]bool{}, + statuses: map[string]AccountStatus{}, + failures: map[string]FailureStatus{}, + } +} + +func (s *fakeStore) HandleSeen(handle string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.handles[handle], nil +} + +func (s *fakeStore) MarkHandle(handle string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.handles[handle] = true + return nil +} + +func (s *fakeStore) PutStatus(account string, st AccountStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + s.statuses[account] = st + return nil +} + +func (s *fakeStore) Statuses() (map[string]AccountStatus, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]AccountStatus{} + for k, v := range s.statuses { + out[k] = v + } + return out, nil +} + +func (s *fakeStore) PutFailure(account string, f FailureStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures[account] = f + return nil +} + +func (s *fakeStore) ClearFailure(account string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.failures, account) + return nil +} + +func (s *fakeStore) Failures() (map[string]FailureStatus, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]FailureStatus{} + for k, v := range s.failures { + out[k] = v + } + return out, nil +} + +// fakeClock is a clock the test moves by hand, so Flush's window needs no real sleep. +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +func newTestNotifier(t *testing.T, clock *fakeClock, store *fakeStore, sent *[]map[string]any) *Notifier { + t.Helper() + return &Notifier{ + Now: clock.Now, + Send: func(args map[string]any) error { + *sent = append(*sent, args) + return nil + }, + Accounts: func() (map[string]string, error) { return map[string]string{}, nil }, + Store: store, + EmitShown: func(string, string) error { return nil }, + Logf: t.Logf, + } +} + +func mail(account string, uid int, messageID, subject, senderName string) EventBody { + return EventBody{Account: account, Folder: "INBOX", UID: uint32(uid), MessageID: messageID, Subject: subject, + From: []Person{{Name: senderName, Address: "s@example.com"}}} +} + +// TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder is the review's first test for BLOCKING 1: Flush +// used to walk a map, so which account's notice went out first was whichever order Go's map iteration gave +// that run. Run with -count=50: green every time only once Flush sorts the accounts itself. +func TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + for _, account := range []string{"zeta", "mango", "alpha", "delta"} { + if err := n.Add(mail(account, 1, account+"-m1", account, "Someone")); err != nil { + t.Fatalf("Add(%s): %v", account, err) + } + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + want := []string{"alpha", "delta", "mango", "zeta"} + if len(sent) != len(want) { + t.Fatalf("sent %d notice(s), want %d: %+v", len(sent), len(want), sent) + } + for i, account := range want { + if sent[i]["summary"] != account { + t.Fatalf("sent[%d]'s summary is %v, want %q: accounts must be flushed in sorted order: %+v", + i, sent[i]["summary"], account, sent) + } + } +} + +// TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts is the review's second test for BLOCKING 2: +// a notice node-notifier.send refuses stays pending (nothing is deleted before it is shown), is tried again +// at the next Flush no sooner than RetryBackoff, and another account's notice still goes out meanwhile. +func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + failSend := true + n := &Notifier{ + Now: clock.Now, + Send: func(args map[string]any) error { + if args["summary"] == "Broken" && failSend { + return errors.New("node-notifier.send: no answer") + } + sent = append(sent, args) + return nil + }, + Accounts: func() (map[string]string, error) { return map[string]string{}, nil }, + Store: store, + EmitShown: func(string, string) error { return nil }, + Logf: t.Logf, + } + + if err := n.Add(mail("broke", 1, "b1", "Broken", "Someone")); err != nil { + t.Fatalf("Add(broke): %v", err) + } + if err := n.Add(mail("ok", 1, "o1", "Fine", "Someone")); err != nil { + t.Fatalf("Add(ok): %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 || sent[0]["summary"] != "Fine" { + t.Fatalf("sent = %+v, want only ok's notice: broke's failure must not hold it up", sent) + } + if failures, err := store.Failures(); err != nil || failures["broke"].Count != 1 { + t.Fatalf("Store.Failures() = %+v, %v; want broke recorded once", failures, err) + } + + // Before RetryBackoff has passed, broke's notice is not retried yet. + clock.Advance(30 * time.Second) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 { + t.Fatalf("sent %d notice(s) before RetryBackoff elapsed, want 1 (broke not retried yet): %+v", len(sent), sent) + } + + // Once RetryBackoff has passed, the next Flush tries it again; nothing was lost. + failSend = false + clock.Advance(RetryBackoff) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 2 || sent[1]["summary"] != "Broken" { + t.Fatalf("sent = %+v, want broke's notice too, after RetryBackoff", sent) + } + if failures, _ := store.Failures(); len(failures) != 0 { + t.Errorf("Store.Failures() = %+v, want it cleared once broke's notice was shown", failures) + } +} + +// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3, +// ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the +// first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth +// onward is folded and shown as one notice once the window — a minute from the first of them — is over. +func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + for i := 1; i <= 4; i++ { + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + clock.Advance(10 * time.Second) + } + if len(sent) != 3 { + t.Fatalf("4 mails 10s apart: sent %d notice(s) before the window ends, want 3 shown one by one: %+v", len(sent), sent) + } + for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} { + if sent[i]["summary"] != want { + t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want) + } + } + + // The window is over a minute after it opened (the first mail): the fourth, folded, is shown now. + clock.Advance(Window) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 4 { + t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent) + } + if sent[3]["summary"] != "1 more new mail in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") { + t.Errorf("the fold notice is %+v, want it to count only what the fold holds (1), naming Sender 4", sent[3]) + } +} + +// TestARedeliveredEventIsNeverShownOrCountedTwice is the review's fourth test: a redelivery of a mail already +// shown is dropped (the Store knows it), and a redelivery of a mail still buffered, waiting to be folded, is +// never counted a second time toward the fold. +func TestARedeliveredEventIsNeverShownOrCountedTwice(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + one := mail(account, 1, "m1", "Hi", "Alice") + if err := n.Add(one); err != nil { + t.Fatalf("Add: %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 { + t.Fatalf("sent %d notice(s), want 1", len(sent)) + } + if err := n.Add(one); err != nil { // redelivered after it was shown + t.Fatalf("Add (redelivered, already shown): %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 { + t.Fatalf("a redelivered, already-shown event was shown again: sent now %+v", sent) + } + + for i := 2; i <= 4; i++ { + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), "Hi", fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + } + fourth := mail(account, 4, "m4", "Hi", "Sender 4") + if err := n.Add(fourth); err != nil { // redelivered while still buffered, folded, not yet shown + t.Fatalf("Add (redelivered, buffered): %v", err) + } + + clock.Advance(Window) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 4 { + t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent) + } + if sent[3]["summary"] != "1 more new mail in home" { + t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the fold's 1", sent[3]) + } +} + +// TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange is the review's fifth test: the dedupe key is the +// account and the message id wherever one is given, so a UIDVALIDITY reset — which changes every UID in a +// folder — cannot make an already-shown mail look new. Only a message with no message id falls back to its +// folder, UIDVALIDITY and UID, and that fallback still tells two different mails apart. +func TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + first := EventBody{Account: account, Folder: "INBOX", UID: 5, UIDValidity: 1, MessageID: "stable-id", + Subject: "Hi", From: []Person{{Address: "a@example.com"}}} + if err := n.Add(first); err != nil { + t.Fatalf("Add: %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 { + t.Fatalf("sent %d, want 1", len(sent)) + } + + // The same message, redelivered after a UIDVALIDITY reset changed its UID: same message id, not shown again. + resent := EventBody{Account: account, Folder: "INBOX", UID: 99, UIDValidity: 2, MessageID: "stable-id", + Subject: "Hi", From: []Person{{Address: "a@example.com"}}} + if err := n.Add(resent); err != nil { + t.Fatalf("Add (UIDVALIDITY reset, same message id): %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 1 { + t.Fatalf("a mail with the same message id after a UIDVALIDITY reset was shown again: sent now %+v", sent) + } + + // Two different mails with no message id, at the same UID but a different UIDVALIDITY, are not confused. + noID1 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 1, Subject: "No id", + From: []Person{{Address: "a@example.com"}}} + noID2 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 2, Subject: "No id, new validity", + From: []Person{{Address: "a@example.com"}}} + if err := n.Add(noID1); err != nil { + t.Fatalf("Add noID1: %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if err := n.Add(noID2); err != nil { + t.Fatalf("Add noID2: %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 3 { + t.Fatalf("sent %d, want 3: noID1 and noID2 are different mails, their UIDVALIDITY differs: %+v", len(sent), sent) + } +} + +// TestQualifyingRulesStillHold keeps the original qualifying rules proven (an already-read mail, a mail from +// the account's own address, and a mail outside the inbox are never shown), now that buffering is per window +// rather than per quiet period. +func TestQualifyingRulesStillHold(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := &Notifier{ + Now: clock.Now, + Send: func(args map[string]any) error { sent = append(sent, args); return nil }, + Accounts: func() (map[string]string, error) { return map[string]string{"work": "me@work.example"}, nil }, + Store: store, + EmitShown: func(string, string) error { return nil }, + Logf: t.Logf, + } + already := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old", + From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}} + own := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent", + From: []Person{{Address: "me@work.example"}}} + elsewhere := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed", + From: []Person{{Address: "carol@example.com"}}} + for _, e := range []EventBody{already, own, elsewhere} { + if err := n.Add(e); err != nil { + t.Fatalf("Add(%+v): %v", e, err) + } + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 0 { + t.Fatalf("sent %+v, want nothing: an already-read mail, the account's own mail and mail outside the inbox never qualify", sent) + } +} + +// TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail is review round 2 of issue 800's first test (BLOCKING): +// a send that fails keeps its mail pending; once the cooldown has passed, but before the next Flush retries +// it, a new qualifying mail arrives. Neither mail is lost: both are shown once the retry succeeds. +func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + failSend := true + n := &Notifier{ + Now: clock.Now, + Send: func(args map[string]any) error { + if failSend { + return errors.New("node-notifier.send: no answer") + } + sent = append(sent, args) + return nil + }, + Accounts: func() (map[string]string, error) { return map[string]string{}, nil }, + Store: store, + EmitShown: func(string, string) error { return nil }, + Logf: t.Logf, + } + + account := "home" + if err := n.Add(mail(account, 1, "m1", "Mail 1", "Sender 1")); err != nil { + t.Fatalf("Add mail 1: %v", err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 0 { + t.Fatalf("sent %d notice(s), want 0: the send failed", len(sent)) + } + + // The cooldown passes, but no retry has happened yet (Flush is not called again before the new mail + // arrives): a buffer this old must not be thrown away for a fresh one. + clock.Advance(RetryBackoff) + if err := n.Add(mail(account, 2, "m2", "Mail 2", "Sender 2")); err != nil { + t.Fatalf("Add mail 2: %v", err) + } + + failSend = false + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 2 { + t.Fatalf("sent %d notice(s), want 2 (mail 1's retry and mail 2, neither lost): %+v", len(sent), sent) + } + for i, want := range []string{"Mail 1", "Mail 2"} { + if sent[i]["summary"] != want { + t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want) + } + } +} + +// TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test, +// replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying +// mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at +// 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves +// (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s) +// still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own +// preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail +// (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's). +func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + deltas := []time.Duration{0, 50 * time.Second, 5 * time.Second, 15 * time.Second, 5 * time.Second, 5 * time.Second} + for i, d := range deltas { + clock.Advance(d) + if err := n.Add(mail(account, i+1, fmt.Sprintf("m%d", i+1), fmt.Sprintf("Mail %d", i+1), fmt.Sprintf("Sender %d", i+1))); err != nil { + t.Fatalf("Add mail %d: %v", i+1, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush at mail %d: %v", i+1, err) + } + } + if len(sent) != 4 { + t.Fatalf("sent %d notice(s), want 4 shown one by one (0s, 50s, 55s, 70s): %+v", len(sent), sent) + } + for i, want := range []string{"Mail 1", "Mail 2", "Mail 3", "Mail 4"} { + if sent[i]["summary"] != want { + t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want) + } + } + + // The fold's own window — a minute from its first mail, 75s — ends at 135s. + clock.Advance(55 * time.Second) // now at 80s + 55s = 135s + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 5 { + t.Fatalf("sent %d notice(s), want 5 (75s and 80s folded into one, at 135s): %+v", len(sent), sent) + } + if sent[4]["summary"] != "2 more new mails in home" || !strings.Contains(sent[4]["body"].(string), "Sender 6") { + t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 6 (80s, the latest)", sent[4]) + } +} + +// TestASteadyTrickleNeverFoldsForever is review round 3 of issue 800's second test: the bug the round found +// (a session that only resets after a quiet minute folds a steady stream forever, even without a burst). One +// mail every 50 seconds, 20 of them: each one's preceding 60 seconds holds only the one before it, so N never +// exceeds 2, and all 20 are shown at once — none ever fold. +func TestASteadyTrickleNeverFoldsForever(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + for i := 1; i <= 20; i++ { + if i > 1 { + clock.Advance(50 * time.Second) + } + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush at mail %d: %v", i, err) + } + if len(sent) != i { + t.Fatalf("after mail %d, sent %d notice(s), want %d: a steady trickle must never fold: %+v", i, len(sent), i, sent) + } + } + for i, got := range sent { + want := fmt.Sprintf("Mail %d", i+1) + if got["summary"] != want { + t.Errorf("sent[%d] = %+v, want summary %q", i, got, want) + } + } +} + +// TestFiveMailsWithinTenSecondsFoldAfterThree is review round 3 of issue 800's third test: five mails within +// ten seconds — the first three shown at once, the fourth opens a fold, the fifth joins it, and the fold is +// shown 60 seconds after the fourth (the fold's own first mail) as one notice. +func TestFiveMailsWithinTenSecondsFoldAfterThree(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + for i := 1; i <= 5; i++ { + if i > 1 { + clock.Advance(2 * time.Second) + } + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush at mail %d: %v", i, err) + } + } + if len(sent) != 3 { + t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth and fifth folded, not yet due: %+v", len(sent), sent) + } + + // The fold is due 60 seconds after its own first mail (the fourth, at 6s). + clock.Advance(Window) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 4 { + t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent) + } + if sent[3]["summary"] != "2 more new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") { + t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 5 (the latest)", sent[3]) + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/run_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/run_test.go new file mode 100644 index 00000000..8a725595 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/run_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// TestRunStartsTheWatcherEvenWhenStdinIsAtEOF proves the watcher runs beside stdio.Serve in the one process the +// node's runtime launches (novox/hq ADR 0378 §9, as mesh-knowledge's serve starts go hear(k) and go +// background(k) before stdio.Serve), not only in a separate process something keeps asking over stdin. run is +// given stdin already at EOF, exactly what systemd gave the old separate `notify` process: it exited at once, +// every five seconds (novox/hq issue 859). The watcher must still be running once run has returned — proved by +// making it actually answer after run returns, not by the absence of a signal that nothing but the test's own +// cleanup could ever raise. +func TestRunStartsTheWatcherEvenWhenStdinIsAtEOF(t *testing.T) { + started := make(chan struct{}) + ping := make(chan struct{}) + pong := make(chan struct{}) + + fakeWatcher := func(n *Notifier) { + close(started) + for range ping { + pong <- struct{}{} + } + } + + n := &Notifier{Store: newFakeStore()} + n.init() + + done := make(chan error, 1) + go func() { done <- run(n, fakeWatcher, strings.NewReader(""), io.Discard, nil) }() + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("the watcher was not started before stdio.Serve returned") + } + + select { + case err := <-done: + if err != nil { + t.Fatalf("run over stdin at EOF: %v", err) + } + case <-time.After(time.Second): + t.Fatal("run did not return once stdin reached EOF, as systemd gives a process nobody talks to") + } + + // run has returned (stdio.Serve hit EOF at once); the watcher must still be live and answering, not merely + // not yet torn down. A watcher that stopped when run returned would never take this ping. + select { + case ping <- struct{}{}: + case <-time.After(time.Second): + t.Fatal("the watcher is no longer running once run returned: it must not stop just because stdio.Serve did") + } + select { + case <-pong: + case <-time.After(time.Second): + t.Fatal("the watcher did not answer once run returned") + } + close(ping) +} + +// TestWatchRetriesASubscribeRefusalWithBackoffAndNeverExits proves watch no longer exits the process on a +// subscribe the runtime refuses — the old os.Exit(1) took mail-notifier.status and mail_notifier_health down +// with it — but retries with backoff and logs each refusal, as modules/mesh-knowledge's hear does (novox/hq +// issue 859, review round 1). A subscribe that fails twice then succeeds must leave the process serving (run +// still returns normally over stdin at EOF, never os.Exit) and the watcher subscribed (its third, successful +// call). +func TestWatchRetriesASubscribeRefusalWithBackoffAndNeverExits(t *testing.T) { + var calls int32 + subscribed := make(chan struct{}) + fakeSubscribe := func(pattern string, handle func(stdio.Envelope) error) error { + if atomic.AddInt32(&calls, 1) <= 2 { + return errors.New("the runtime refused this subscribe") + } + close(subscribed) + return nil + } + noSleep := func(time.Duration) {} + + n := &Notifier{Store: newFakeStore()} + n.init() + + done := make(chan error, 1) + go func() { + done <- run(n, func(n *Notifier) { watchWith(n, fakeSubscribe, noSleep) }, + strings.NewReader(""), io.Discard, nil) + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("run over stdin at EOF: %v", err) + } + case <-time.After(time.Second): + t.Fatal("run did not return once stdin reached EOF: a subscribe refusal must not hold up or kill the process") + } + + select { + case <-subscribed: + case <-time.After(time.Second): + t.Fatal("the watcher never subscribed after its first two refusals") + } + + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("subscribe was called %d time(s), want 3 (two refusals, then the one that succeeded)", got) + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/send_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/send_test.go new file mode 100644 index 00000000..d1f32000 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/send_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +// TestSendKeyNamesThisMachine proves Send asks its own machine's node-notifier, not the bare seat: a bare +// "seat:node-notifier.send" is answered by whichever machine's node-notifier takes it first, so notices sent +// from g14 and shanks were lost to "503 no responders" or another machine's desktop (novox/hq issue 865). +// desk-channel names the machine it asks the same way ("seat:node-notifier.send@"+machine, +// modules/desk-channel/cmd/desk-channel/main.go); sendKey must read this machine's own name the same way the +// SDK's own Emit does, from MESH_NODE, which the runtime sets. +func TestSendKeyNamesThisMachine(t *testing.T) { + t.Setenv("MESH_NODE", "g14") + + if got, want := sendKey(), "seat:node-notifier.send@g14"; got != want { + t.Fatalf("sendKey() = %q, want %q (it must name this machine, as desk-channel does)", got, want) + } +} diff --git a/modules/mesh-mail-notifier/go.mod b/modules/mesh-mail-notifier/go.mod new file mode 100644 index 00000000..7d050d08 --- /dev/null +++ b/modules/mesh-mail-notifier/go.mod @@ -0,0 +1,5 @@ +module mesh-mail-notifier + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/mesh-mail-notifier/go.sum b/modules/mesh-mail-notifier/go.sum new file mode 100644 index 00000000..3b200c4c --- /dev/null +++ b/modules/mesh-mail-notifier/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-mail-notifier/module.json b/modules/mesh-mail-notifier/module.json new file mode 100644 index 00000000..dec4d77a --- /dev/null +++ b/modules/mesh-mail-notifier/module.json @@ -0,0 +1,67 @@ +{ + "module": "mesh-mail-notifier", + "version": "1", + "runs-as": "mesh-mail-notifier", + "claims": [ + { + "name": "mail-notifier", + "scope": "node", + "serves": ["status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:node-notifier.send", + "seat:mail-store.accounts" + ], + "emits": [ + "shown" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "tools": [ + "mail-notifier.status", + "mail_notifier_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-notifier", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-notifier", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-notifier" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-notifier", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-notifier", + "binary": "mesh-mail-notifier", + "loads": [ + "mesh-mail-notifier" + ] + } + ] + } +} diff --git a/modules/mesh-mail/README.md b/modules/mesh-mail/README.md new file mode 100644 index 00000000..51d133d9 --- /dev/null +++ b/modules/mesh-mail/README.md @@ -0,0 +1,81 @@ +# mesh-mail — the operator's mail accounts, watched and said on the bus + +One module holds the operator's mail accounts, watches them, and says each change of a message on the bus for the +modules the operator grants it to (novox/hq ADR 0359, research 049 document 02). One instance, on the control-node. +Reading is the first stage: accounts of the kind `imap`, read-only — nothing here flags, moves, deletes or drafts. +Sending is `mail_send` alone (novox/hq ADR 0361 §2): it sends nothing itself, and reaches an account's outgoing +server only once the operator approves it on a channel that proves who answered. + +## Its seats (novox/hq ADR 0378 as amended by ADR 0379, issue 800 part A) + +It declares three mail seats and claims the one it holds: + +- **mail-store** (held once for the mesh) — serves `accounts`, `search`, `read` and `send`; mesh-mail claims it, + through the same handlers as its own `mail_*` tools. The catalogue's own check (`checks/mailstore`) refuses any + other manifest that claims it. +- **mail-webclient** (held once for the mesh) — serves `open` and `status`; unheld until issue 580. +- **mail-notifier** (held once per machine, needing `node-notifier` there) — serves `status`; held by + mesh-mail-notifier (issue 800 part B), a module of its own built from this one's source, which tells the + operator of each new inbox mail through `node-notifier.send`. + +## What it does + +- **Watches.** For each account it keeps one connection: IDLE on the inbox, ended and begun again every two minutes + (which proves the server still answers), and a look at every other folder every five minutes. Folders are opened + read-only and bodies are peeked, so watching never marks a message seen. +- **Indexes.** One row per message and folder in its own database (`postgres-database`): metadata, flags, the text + for search, and the attachments' references. Each attachment is kept once, by its SHA-256, in its bucket + (`s3-bucket`, from `minio`). +- **Says each change.** A message added, its flags changed, or removed is said as the event + `mail..message.`: account, folder, uid, message id, date, sender, recipients, + subject, size, flags and attachment references; never the body, never an attachment's bytes. The events go to the + module's own stream `MAIL`, kept seven days, never to the shared EVENTS stream (novox/hq ADR 0363); a module hears an + account's part only when its `consumes` names mesh-mail and the setting `grants` names it for that account. The first look at a + folder indexes what is there and says nothing: the bus hears what changed since the watch began. The keeper writes + each event in the same step as the change it says; the bundle says it through the runtime and removes it once the + bus took it. + +## Its settings, both trusted + +`accounts` — per account name (a lower-case token, which names the account on the bus and its secret): + +```json +{"mesh": {"kind": "imap", "address": "", + "incoming": {"host": "", "port": 993, "security": "tls"}, + "outgoing": {"host": "", "port": 465, "security": "tls"}, + "folders": ["optional: the folders watched besides the inbox; every folder when left out"]}} +``` + +`outgoing` is the server `mail_send` sends through: host, port, and `security` `tls` (465, implicit) or `starttls` +(587). A second `imap` account on another server is one more entry and one more password; no code changes. `user` +signs in under another name than the address. + +`grants` — per account, who may read its mail, and who may send from it: a module's name (on every machine), +`.`, or `person.`. Nobody by default. It restricts the stream `MAIL` on the bus (a person's +entry grants nothing there) and the tools `mail_search`, `mail_read` and `mail_send` — the same check for all three. + +The password of an account is the own secret `mail-credential-`, given at the desk; the mesh never makes one. + +## Its tools + +| Tool | Answers | +|---|---| +| `mail_accounts` | each account's kind, state and why, when its server last answered, its folders; the address only to a caller granted it | +| `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body | +| `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account | +| `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-` while its password is not given | +| `mail_send` | raises an ask on the operator channel (the account, the recipients, the subject, the first lines) for a caller granted the account; answers at once with the ask's id. Sends nothing itself | +| `mail_send_status` | where a send stands, by its ask id: waiting, sent, declined, ended without a choice, or not proven | + +The caller is the runtime's word, never an argument. `mail_send` sends through the account's outgoing server only +once the operator's Approve comes back on a channel that proves who answered, and once: a warrant delivered twice +never sends twice (novox/hq ADR 0361 §2). + +The mail-store seat's four verbs, `mail-store.accounts`, `mail-store.search`, `mail-store.read` and +`mail-store.send`, answer through the same handlers as `mail_accounts`, `mail_search`, `mail_read` and `mail_send` +above — the same grants, the same refusals, the same ask-then-Approve for a send. + +## Its processes + +`prepare` brings the index's schema to this version's (numbered migrations, and `schema.sql` for a fresh database). +`keep` watches the accounts and is judged by its unit. `watch` judges every minute and is judged by `mail_health`. diff --git a/modules/mesh-mail/cmd/mesh-mail/ask_test.go b/modules/mesh-mail/cmd/mesh-mail/ask_test.go new file mode 100644 index 00000000..41f20759 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/ask_test.go @@ -0,0 +1,218 @@ +package main + +// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any +// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the +// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in +// a form the content rule lets through (novox/hq issue 868, "Decided" note and review round 1). These tests +// build real asks through mail_send and judge them with the real rule, copied verbatim as content_rule.go. + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details +// together. Useful for a readability check (nothing unmasked is visible anywhere), but never for judging +// whether messenger would refuse the ask as a whole — judgedWords is that (novox/hq issue 868, review +// round 1): Details is filtered line by line, not judged with the rest (holder.go's sayOn). +func wholeAsk(r *sendRouter, id string) string { + a := r.published[id] + return a.Headline + "\n" + a.Explanation + "\n" + a.Details +} + +// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's +// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect. +// Details is never part of it. +func judgedWords(a asks.Ask) string { + words := []string{a.Headline, a.Explanation, a.OnExpiry} + for _, o := range a.Options { + words = append(words, o.Label, o.Does) + } + return strings.Join(words, "\n") +} + +// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger +// itself judges an ask as a whole, on judgedWords alone. +func mustNotBeRefused(t *testing.T, a asks.Ask) { + t.Helper() + if refusal, ok := Check(judgedWords(a)); !ok { + t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a)) + } +} + +// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address +// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and +// passes the real content rule. This is the first test issue 868's decision asked for; it fails today +// because AskMailSend puts the raw address into the ask. +func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"}, + "subject": "Testing issue 868", "body": "Does the ask reach the operator now?"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + if json.Unmarshal([]byte(text), &out) != nil { + t.Fatalf("not JSON: %s", text) + } + id, _ := out["ask"].(string) + if id == "" { + t.Fatalf("%s", text) + } + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") { + t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole) + } + if !strings.Contains(whole, "your account mesh") { + t.Errorf("the ask does not name the own account:\n%s", whole) + } + mustNotBeRefused(t, router.published[id]) +} + +// (2) A recipient outside the operator's own accounts is said without its address or host: its first +// letter, an ellipsis and the domain's labels as words. +func TestMailSendAskForOutsideRecipientCarriesNoAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) // to: ops@example.org, lead@example.org + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") { + t.Errorf("the ask carries the recipients' address or host:\n%s", whole) + } + for _, want := range []string{"o… at example org", "l… at example org"} { + if !strings.Contains(whole, want) { + t.Errorf("the ask does not say %q:\n%s", want, whole) + } + } + mustNotBeRefused(t, router.published[id]) +} + +// (3) More than three recipients are counted rather than said one by one. +func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", + "to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"}, + "subject": "Many recipients", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") { + t.Errorf("the ask carries a mail address among many recipients:\n%s", whole) + } + if !strings.Contains(whole, "and 2 more") { + t.Errorf("the ask does not count the recipients past the first three:\n%s", whole) + } + mustNotBeRefused(t, router.published[id]) +} + +// (4) A subject holding a mail address and a link has both masked before the ask carries it. +func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, + "subject": "Contact ops@example.org or see https://example.com/issue/42", + "body": "See https://example.com/issue/42 and write to ops@example.org for details."} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") { + t.Errorf("the ask carries an unmasked address or link:\n%s", whole) + } + if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") { + t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole) + } + mustNotBeRefused(t, router.published[id]) +} + +// (5) review round 1: a subject the masking does not catch (an IP, a two-slash path-shaped reference, a +// 40-character hex hash) does not refuse the whole ask: the real content rule (content_rule.go) still judges +// the masked explanation as refused, so the subject and first lines are left out of it and pointed at Details +// instead — the ask still names the account and recipients, and still reaches the operator. +func TestMailSendAskFallsBackToDetailsWhenTheSubjectStillRefuses(t *testing.T) { + for _, subject := range []string{ + "Invoice 2026/10/15", + "server 10.0.0.1", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 40 hex characters: reHex's shape + } { + t.Run(subject, func(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, "subject": subject, "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + a := router.published[id] + mustNotBeRefused(t, a) + if !strings.Contains(a.Explanation, "mesh") { + t.Errorf("the explanation does not name the account:\n%s", a.Explanation) + } + if !strings.Contains(a.Explanation, "o… at example org") { + t.Errorf("the explanation does not name the recipient:\n%s", a.Explanation) + } + if strings.Contains(a.Explanation, subject) { + t.Errorf("the explanation still carries the subject that refused the ask:\n%s", a.Explanation) + } + if !strings.Contains(a.Details, "Details") && !strings.Contains(a.Explanation, "Details") { + t.Errorf("the explanation does not point at Details:\n%s", a.Explanation) + } + if !strings.Contains(a.Details, subject) { + t.Errorf("Details does not carry the subject (filtered line by line by messenger, as the subject's own line): %s", a.Details) + } + }) + } +} + +// (6) review round 1: a recipient written with a display name ("Name ") is matched against the +// operator's own accounts, and masked, by its address alone. +func TestMailSendAskMatchesADisplayNameRecipientByItsAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"Jochen Schoubben "}, + "subject": "Testing a display name", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "Jochen Schoubben") { + t.Errorf("the ask carries the display-name recipient's address or name:\n%s", whole) + } + if !strings.Contains(whole, "your account mesh") { + t.Errorf("the ask does not match the display-name recipient to its own account:\n%s", whole) + } + mustNotBeRefused(t, router.published[id]) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/config.go b/modules/mesh-mail/cmd/mesh-mail/config.go new file mode 100644 index 00000000..4591f9f9 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/config.go @@ -0,0 +1,260 @@ +package main + +// The operator's mail accounts and who may read them (novox/hq ADR 0359 §1, §4, §5): two trusted settings, each a +// file the mesh writes from the module's settings and changes only on the operator's approval. `accounts` says which +// server each account is on and how to sign in (never a credential); `grants` says, per account, which callers may +// read its mail. The password of an account is the module's own secret mail-credential-, given at the desk +// (ADR 0283 §6), at the path the secrets directory and that name make. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// The kinds of account an adapter exists for. Only `imap` is built (ADR 0359 §5); `imap-oauth` and `graph` are later +// decisions (§7), and an account of a kind not listed here is refused by name. +const KindIMAP = "imap" + +// Kinds is every kind this version has an adapter for. +var Kinds = []string{KindIMAP} + +// SecretFamily is the prefix of every account's own secret: mail-credential- (ADR 0283 §6). +const SecretFamily = "mail-credential-" + +// accountName is what an account may be called: a member name of the secret family (ADR 0283 §6) and one token of a +// bus subject, so never a dot, a slash or an upper-case letter. +var accountName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}$`) + +// Server is where an account's mail is reached. +type Server struct { + Host string `json:"host"` + Port int `json:"port"` + // Security is tls (TLS from the first byte) or starttls (plain, then upgraded before signing in). Nothing + // signs in over a connection that is not encrypted. + Security string `json:"security"` +} + +// Address is host:port. +func (s Server) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) } + +// Account is one of the operator's mail accounts, as the setting `accounts` declares it. +type Account struct { + // Name is the key the account has in the setting; set when the setting is read. + Name string `json:"-"` + Kind string `json:"kind"` + // Address is the account's mail address, said only to a caller granted the account. + Address string `json:"address"` + // User is the name it signs in with; the address when empty. + User string `json:"user,omitempty"` + Incoming Server `json:"incoming"` + Outgoing *Server `json:"outgoing,omitempty"` + // Folders are the folders watched besides the inbox; every folder the server lists when empty. + Folders []string `json:"folders,omitempty"` +} + +// SignInAs is the name the account signs in with. +func (a Account) SignInAs() string { + if a.User != "" { + return a.User + } + return a.Address +} + +// Secret is the name of the account's own secret. +func (a Account) Secret() string { return SecretFamily + a.Name } + +// AccountsFile is the shape of the trusted setting `accounts`, as the mesh writes it. +type AccountsFile struct { + Accounts map[string]Account `json:"accounts"` +} + +// ReadAccounts reads the accounts setting, refusing it whole when any account in it cannot be watched: an account +// half-understood would be watched wrongly, and the operator approved the setting as one. +func ReadAccounts(path string) ([]Account, error) { + if path == "" { + return nil, errors.New("the module was started without its accounts setting (MESH_MAIL_ACCOUNTS)") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("the accounts setting cannot be read: %w", err) + } + return ParseAccounts(raw) +} + +// ParseAccounts reads the accounts setting's content. +func ParseAccounts(raw []byte) ([]Account, error) { + var f AccountsFile + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("the accounts setting is not the module's: %v", err) + } + var out []Account + var problems []string + for name, a := range f.Accounts { + a.Name = name + if err := a.Check(); err != nil { + problems = append(problems, err.Error()) + continue + } + out = append(out, a) + } + if len(problems) > 0 { + sort.Strings(problems) + return nil, errors.New("the accounts setting is refused: " + strings.Join(problems, "; ")) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// Check says why an account cannot be watched, or nil. +func (a Account) Check() error { + say := func(format string, args ...any) error { + return fmt.Errorf("the account %q: %s", a.Name, fmt.Sprintf(format, args...)) + } + if !accountName.MatchString(a.Name) { + return say("is not an account's name: a lower-case letter or digit, then lower-case letters, digits and hyphens, at most 40") + } + known := false + for _, k := range Kinds { + known = known || a.Kind == k + } + if !known { + return say("is of the kind %q, and this version watches only %s", a.Kind, strings.Join(Kinds, ", ")) + } + if !strings.Contains(a.Address, "@") { + return say("has no mail address") + } + if err := a.Incoming.check("incoming"); err != nil { + return say("%v", err) + } + if a.Outgoing != nil { + if err := a.Outgoing.check("outgoing"); err != nil { + return say("%v", err) + } + } + for _, f := range a.Folders { + if strings.TrimSpace(f) == "" || strings.EqualFold(f, "INBOX") { + return say("lists the folder %q, and the inbox is always watched", f) + } + } + return nil +} + +func (s Server) check(which string) error { + switch { + case strings.TrimSpace(s.Host) == "" || strings.ContainsAny(s.Host, " /:"): + return fmt.Errorf("its %s server has no host name", which) + case s.Port <= 0 || s.Port > 65535: + return fmt.Errorf("its %s server's port %d is not a port", which, s.Port) + case s.Security != "tls" && s.Security != "starttls": + return fmt.Errorf("its %s server's security is %q, and only tls or starttls signs in", which, s.Security) + } + return nil +} + +// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the +// family: /mail-credential-.secret. +func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") } + +// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the +// family, so a member not given is no file (ADR 0283 §6). +func ReadSecret(dir string, a Account) (string, bool, error) { + raw, err := os.ReadFile(SecretPath(dir, a)) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + pw := strings.TrimRight(string(raw), "\r\n") + if pw == "" { + return "", false, nil + } + return pw, true, nil +} + +// SecretGiven says whether the account's own secret was given. +// +// It looks at the file and never reads it: whoever judges whether a password was given has no need of the password. +// A file that is there and not empty was given; whether it signs in is the keeper's to say. +func SecretGiven(dir string, a Account) bool { + info, err := os.Stat(SecretPath(dir, a)) + return err == nil && info.Mode().IsRegular() && info.Size() > 0 +} + +// ---- grants ------------------------------------------------------------------------------------------------------ + +// Grants is the trusted setting `grants`: per account, the callers that may read its mail (ADR 0359 §4). An entry is a +// module's name (granted wherever it runs), a module on one machine (`.`) or a person +// (`person.`), as the runtime names callers. +type Grants map[string][]string + +// GrantsFile is the shape of the setting as the mesh writes it. +type GrantsFile struct { + Grants Grants `json:"grants"` +} + +// ReadGrants reads the grants setting. A setting that cannot be read grants nothing, and says so. +func ReadGrants(path string) (Grants, error) { + if path == "" { + return Grants{}, errors.New("the module was started without its grants setting (MESH_MAIL_GRANTS)") + } + raw, err := os.ReadFile(path) + if err != nil { + return Grants{}, fmt.Errorf("the grants setting cannot be read: %w", err) + } + var f GrantsFile + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return Grants{}, fmt.Errorf("the grants setting is not the module's: %v", err) + } + if f.Grants == nil { + f.Grants = Grants{} + } + return f.Grants, nil +} + +// Unproven is the caller the runtime proved nobody to be; it is granted nothing. +const Unproven = "unproven" + +// Allows says whether the caller may read the account's mail. Nobody is granted an account by default, an unproven +// caller never, and a wildcard is no entry. +func (g Grants) Allows(caller, account string) bool { + if caller == "" || caller == Unproven { + return false + } + for _, entry := range g[account] { + if entry == "" || strings.ContainsAny(entry, "*>") { + continue + } + if entry == caller { + return true + } + // A module's name alone grants it on every machine: the caller is .. + if !strings.Contains(entry, ".") { + if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") { + return true + } + } + } + return false +} + +// Granted is the accounts, of those given, the caller may read. +func (g Grants) Granted(caller string, accounts []Account) []string { + var out []string + for _, a := range accounts { + if g.Allows(caller, a.Name) { + out = append(out, a.Name) + } + } + return out +} diff --git a/modules/mesh-mail/cmd/mesh-mail/config_test.go b/modules/mesh-mail/cmd/mesh-mail/config_test.go new file mode 100644 index 00000000..21acbb3a --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/config_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The first stage's account, as the operator sets the setting `accounts` (ADR 0359 §5): its address and its server, +// never a credential; its password is the own secret mail-credential-mesh. +const stageOne = `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be", + "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}}}` + +func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) { + got, err := ParseAccounts([]byte(stageOne)) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "mesh" || got[0].Kind != KindIMAP || got[0].SignInAs() != "jschoubben@novox.be" || + got[0].Incoming.Address() != "mail.novox.be:993" || got[0].Secret() != "mail-credential-mesh" || got[0].Outgoing != nil { + t.Fatalf("%+v", got) + } + if p := SecretPath("/state", got[0]); p != "/state/mail-credential-mesh.secret" { + t.Fatalf("the secret is placed at %s", p) + } +} + +// A second account of the kind imap on another server — Gmail with an app password, another member of the family — +// is configuration only: no code changes for it. Each account may also say its outgoing server, which mail_send +// uses to send through it (novox/hq ADR 0361 §1). +func TestASecondIMAPAccountIsConfigurationOnly(t *testing.T) { + got, err := ParseAccounts([]byte(`{"accounts": { + "mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}, + "gmail": {"kind": "imap", "address": "someone@gmail.com", "incoming": {"host": "imap.gmail.com", "port": 993, "security": "tls"}, + "outgoing": {"host": "smtp.gmail.com", "port": 465, "security": "tls"}, "folders": ["[Gmail]/Sent Mail"]}}}`)) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Name != "gmail" || got[0].Secret() != "mail-credential-gmail" || got[0].Outgoing.Address() != "smtp.gmail.com:465" || + !reflect.DeepEqual(got[0].Folders, []string{"[Gmail]/Sent Mail"}) { + t.Fatalf("%+v", got) + } +} + +func TestAnAccountThatCannotBeWatchedRefusesTheSetting(t *testing.T) { + for name, raw := range map[string]string{ + "a later kind": `{"accounts": {"work": {"kind": "graph", "address": "a@b.c", "incoming": {"host": "h", "port": 1, "security": "tls"}}}}`, + "no encryption": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 143, "security": "none"}}}}`, + "a name with a dot": `{"accounts": {"my.box": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`, + "a password in it": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "password": "p", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`, + "no address": `{"accounts": {"x": {"kind": "imap", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`, + "the inbox listed": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "folders": ["INBOX"], "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`, + "a bad outgoing one": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}, "outgoing": {"host": "", "port": 465, "security": "tls"}}}}`, + } { + if _, err := ParseAccounts([]byte(raw)); err == nil { + t.Errorf("%s: accepted", name) + } + } + if got, err := ParseAccounts([]byte(`{"accounts": {}}`)); err != nil || len(got) != 0 { + t.Fatalf("the manifest's default: %v %v", got, err) + } +} + +// Grants (ADR 0359 §4): nobody by default; a module's name grants it wherever it runs; a module on one machine, or a +// person, by its full name; never the unproven, never by a wildcard, never one account for another. +func TestGrantsAreByAccountAndByProvenCaller(t *testing.T) { + g := Grants{"mesh": {"triage", "novox.desk", "person.jochen", "*"}} + for caller, want := range map[string]bool{ + "novox.triage": true, "shanks.triage": true, "novox.desk": true, "g14.desk": false, "person.jochen": true, + "person.triage": false, "novox.other": false, Unproven: false, "": false, "novox.*": false, + } { + if got := g.Allows(caller, "mesh"); got != want { + t.Errorf("%s: %v", caller, got) + } + } + if g.Allows("novox.triage", "gmail") || (Grants{}).Allows("novox.triage", "mesh") { + t.Fatal("a grant reached an account it does not name") + } + dir := t.TempDir() + path := filepath.Join(dir, "grants.json") + _ = os.WriteFile(path, []byte(`{"grants": {"mesh": ["triage"]}}`), 0o600) + read, err := ReadGrants(path) + if err != nil || !read.Allows("novox.triage", "mesh") { + t.Fatalf("%v %v", read, err) + } + _ = os.WriteFile(path, []byte(`{"grants": {"mesh": "triage"}}`), 0o600) + if read, err := ReadGrants(path); err == nil || len(read) != 0 { + t.Fatal("a grants setting it cannot read granted something") + } +} + +// Whether a password was given is read from the file's presence and size alone (issue 733): an empty file is not +// given, a file the judge cannot read is still given, and the password is never read to say so. +func TestASecretIsGivenByItsFileNotByReadingIt(t *testing.T) { + dir := t.TempDir() + a := testAccount() + if SecretGiven(dir, a) { + t.Fatal("given before anything was written") + } + _ = os.WriteFile(SecretPath(dir, a), nil, 0o600) + if SecretGiven(dir, a) { + t.Fatal("an empty file is a password") + } + _ = os.WriteFile(SecretPath(dir, a), []byte("pw\n"), 0o600) + if pw, given, _ := ReadSecret(dir, a); !given || pw != "pw" || strings.Contains(pw, "\n") { + t.Fatalf("%q %v", pw, given) + } + // Unreadable to the judge, as a secret owned by root is to the watch: still given. + if err := os.Chmod(SecretPath(dir, a), 0); err != nil { + t.Fatal(err) + } + if !SecretGiven(dir, a) { + t.Fatal("a password the judge cannot read is said not given") + } + src, _ := os.ReadFile("config.go") + body := string(src) + body = body[strings.Index(body, "func SecretGiven("):] + body = body[:strings.Index(body, "\n}\n")] + if strings.Contains(body, "ReadSecret") || strings.Contains(body, "ReadFile") || strings.Contains(body, "os.Open") { + t.Fatalf("SecretGiven reads the password:\n%s", body) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/content_rule.go b/modules/mesh-mail/cmd/mesh-mail/content_rule.go new file mode 100644 index 00000000..2c0b5552 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/content_rule.go @@ -0,0 +1,215 @@ +package main + +// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A +// message carrying an address, a path or anything shaped like a secret is refused here, by the holder, +// because Telegram is not end-to-end encrypted for bots and this rule is the only thing between a +// condition's words and someone else's server. It is not trusted to each source. +// +// Deliberately wider than it must be: a commit id or a long random name is refused too. A source +// that wants its message through says it in words; a refusal is said, never silent. + +import ( + "math" + "regexp" + "strings" + "unicode" +) + +// Refusal says why a text may not leave: the class of what it carried, never the text itself. +type Refusal struct { + Class string // address, path or secret + What string // a few words: "an IPv4 address", "a URL", … +} + +func (r Refusal) String() string { return r.Class + " (" + r.What + ")" } + +var ( + reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`) + reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`) + reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`) + reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`) + rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`) + reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`) + reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`) + reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`) + reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`) + reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`) + reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`) + reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`) +) + +// topLevel are names that end a host name — the generic and country ones a mesh's names use, and the +// private ones (.internal, .lan, .home, .local) a mesh is likelier to. +var topLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + topLevel[t] = true + } +} + +// Check says whether a text may leave the mesh, and when not, why. +func Check(text string) (Refusal, bool) { + switch { + case reURL.MatchString(text): + return Refusal{"address", "a URL"}, false + case reEmail.MatchString(text): + return Refusal{"address", "a mail address"}, false + case reIPv4.MatchString(text): + return Refusal{"address", "an IPv4 address"}, false + case reMAC.MatchString(text): + return Refusal{"address", "a hardware address"}, false + case reIPv6.MatchString(text): + return Refusal{"address", "an IPv6 address"}, false + } + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + if reWinPath.MatchString(text) { + return Refusal{"path", "a drive path"}, false + } + if refusal, ok := randomRun(text); !ok { + return refusal, false + } + for _, word := range strings.FieldsFunc(text, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r) + }) { + w := strings.TrimRight(word, ".:!?") + if isPath(w) { + return Refusal{"path", "a file path"}, false + } + if isHostName(w) { + return Refusal{"address", "a host name"}, false + } + } + return Refusal{}, true +} + +// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is +// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers, +// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one: +// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a +// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a +// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever +// the run holds. +func CheckSecret(text string) (Refusal, bool) { + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRunOutsidePaths(text) +} + +// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes. +func randomRunOutsidePaths(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + for _, piece := range strings.Split(run, "/") { + if len(piece) >= 20 && looksRandom(piece) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + } + return Refusal{}, true +} + +// secretShape is the secret shapes a pattern names. +func secretShape(text string) (Refusal, bool) { + switch { + case rePEM.MatchString(text): + return Refusal{"secret", "a key block"}, false + case reJWT.MatchString(text): + return Refusal{"secret", "a signed token"}, false + case reBotToken.MatchString(text): + return Refusal{"secret", "a bot token"}, false + case reKnown.MatchString(text): + return Refusal{"secret", "a known token shape"}, false + case reAssigned.MatchString(text): + return Refusal{"secret", "a value given to a secret's name"}, false + case reHex.MatchString(text): + return Refusal{"secret", "a long hexadecimal string"}, false + } + return Refusal{}, true +} + +// randomRun is a long unbroken run of characters spread as a random string's are. +func randomRun(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + if looksRandom(run) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + return Refusal{}, true +} + +// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one +// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only. +func isPath(w string) bool { + if w == "" { + return false + } + if strings.HasPrefix(w, "/") && len(w) > 1 { + return true + } + for _, p := range []string{"~/", "./", "../", "$HOME", "${"} { + if strings.HasPrefix(w, p) { + return true + } + } + return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\") +} + +// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind +// (`machine.ace.silent`), which none of these is. +func isHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return topLevel[parts[len(parts)-1]] +} + +// looksRandom: letters and digits mixed, and the characters spread as a random string's are. A +// sentence's words are separated, so only an unbroken run reaches here. +func looksRandom(s string) bool { + var letters, digits int + counts := map[rune]int{} + for _, r := range s { + counts[r]++ + switch { + case unicode.IsLetter(r): + letters++ + case unicode.IsDigit(r): + digits++ + } + } + if letters == 0 || digits == 0 { + // One class only: a word, or a number. A long number of digits alone is a count or a time. + return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0 + } + return entropy(counts, len(s)) >= 3.3 +} + +func hasUpperAndLower(s string) bool { + return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0 +} + +func entropy(counts map[rune]int, n int) float64 { + e := 0.0 + for _, c := range counts { + p := float64(c) / float64(n) + e -= p * math.Log2(p) + } + return e +} diff --git a/modules/mesh-mail/cmd/mesh-mail/copies_test.go b/modules/mesh-mail/cmd/mesh-mail/copies_test.go new file mode 100644 index 00000000..7597d998 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/copies_test.go @@ -0,0 +1,33 @@ +package main + +// content_rule.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR +// 0253 §3), copied here verbatim — byte for byte, only the file renamed, since Go allows nothing else to +// differ (novox/hq issue 868, review round 1). mesh-mail carries it at run time, not only in its tests: the +// ask it builds is checked against the real rule before it is published (send.go), so a subject or a first +// line the masking does not catch (an IP, a hardware address, a path-shaped reference, a hex hash, …) falls +// back to a safe explanation rather than refusing the whole ask. Messenger's content rule stays messenger's: +// this is a pinned copy, never a dependency on it, the way modules/networkmanager/cmd/uplink-tools/copies_test.go +// pins its copy of uplink.go. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestContentRuleCopyMatchesMessengers(t *testing.T) { + src := filepath.Join("..", "..", "..", "messenger", "cmd", "messenger", "content.go") + theirs, err := os.ReadFile(src) + if err != nil { + t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err) + } + mine, err := os.ReadFile("content_rule.go") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(mine, theirs) { + t.Error("mesh-mail's copy of messenger's content.go (content_rule.go) differs from " + src + + ": change this copy to match messenger's content rule, verbatim, whenever it changes") + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/health.go b/modules/mesh-mail/cmd/mesh-mail/health.go new file mode 100644 index 00000000..9eee668c --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/health.go @@ -0,0 +1,170 @@ +package main + +// How mesh-mail is (novox/hq ADR 0359 §6). Its keeper's liveness is its unit's; its watching is judged here, by the +// watch process, once a minute, and left in a status file the bundle's tool `mail_health` reads: +// +// - waits, naming the own secret mail-credential-, for an account whose password nobody gave yet (ADR 0283), +// so the condition is needs-operator and the gate passes; +// - unhealthy, naming the account and why, when its connection is down or its server has not answered for +// SilentAfter (five minutes) — an IDLE that went silent; +// - healthy otherwise. +// +// A fault beside a wait is unhealthy and says both. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// HealthTool is the module's tool the node-engine asks how it is. +const HealthTool = "mail_health" + +// WatchEvery is how often the watch judges; StatusStale how old its verdict may be before the tool says it stopped. +const ( + WatchEvery = time.Minute + StatusStale = 3 * WatchEvery + // SignInGrace is how long a password just given may wait for its keeper to sign in before that is a fault. + SignInGrace = 3 * time.Minute +) + +// Judge is the module's health from what the watch reads: the accounts setting (or why it cannot be read), whether the +// index serves, which passwords were given, and each account's state as its keeper said it. +func Judge(accounts []Account, accountsErr, readyErr error, given func(Account) bool, states map[string]AccountState, now time.Time) health.Answer { + if accountsErr != nil { + return health.Unhealthy(accountsErr.Error()) + } + var problems, fine []string + var waits []health.Wait + if readyErr != nil { + problems = append(problems, "the index cannot serve: "+readyErr.Error()) + } + for _, a := range accounts { + if !given(a) { + waits = append(waits, health.Wait{ + Part: "the mail account " + a.Name, + Secret: a.Secret(), + What: "the password of the mail account " + a.Name, + }) + continue + } + if readyErr != nil { + continue + } + st, said := states[a.Name] + switch { + case !said: + problems = append(problems, fmt.Sprintf("the account %s: its keeper has said nothing of it", a.Name)) + case st.State == AccountWaits: + if now.Sub(st.Said) > SignInGrace { + problems = append(problems, fmt.Sprintf("the account %s: its password was given, and its keeper has not signed in for %s", + a.Name, now.Sub(st.Said).Round(time.Second))) + } else { + fine = append(fine, a.Name+" is signing in") + } + case st.State == AccountFailing: + problems = append(problems, fmt.Sprintf("the account %s is not watched: %s", a.Name, st.Why)) + case st.Heard.IsZero(): + problems = append(problems, fmt.Sprintf("the account %s: its server has not answered since its keeper started", a.Name)) + case now.Sub(st.Heard) > SilentAfter: + problems = append(problems, fmt.Sprintf("the account %s: its server has not answered for %s (its IDLE on the inbox is silent)", + a.Name, now.Sub(st.Heard).Round(time.Second))) + default: + fine = append(fine, fmt.Sprintf("%s is %s (%s)", a.Name, st.State, st.Why)) + } + } + waitSaid := "" + if len(waits) > 0 { + var names []string + for _, w := range waits { + names = append(names, w.Secret) + } + waitSaid = "waits for its password: " + strings.Join(names, ", ") + } + switch { + case len(problems) > 0: + why := strings.Join(problems, "; ") + if waitSaid != "" { + why += "; and it " + waitSaid + } + return health.Unhealthy(why, waits...) + case len(waits) > 0: + why := "nothing is wrong, and it " + waitSaid + if len(fine) > 0 { + why += "; " + strings.Join(fine, "; ") + } + return health.Waiting(why, waits...) + case len(accounts) == 0: + return health.Healthy("no mail account is declared, so nothing is watched") + } + return health.Healthy(fmt.Sprintf("%d account(s) watched: %s", len(accounts), strings.Join(fine, "; "))) +} + +// Status is the watch's verdict as it leaves it: the answer, and when it looked. +type Status struct { + health.Answer + At time.Time `json:"at"` +} + +func writeStatus(path string, s Status) error { + raw, err := json.Marshal(s) + if err != nil { + return err + } + tmp := path + ".next" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ReadStatus is the watch's last verdict, as the tool answers it: unhealthy when there is none, when it cannot be read, +// or when it is older than StatusStale. +func ReadStatus(path string, now time.Time) health.Answer { + raw, err := os.ReadFile(path) + if err != nil { + return health.Unhealthy("the watch has left no verdict yet: " + err.Error()) + } + var s Status + if err := json.Unmarshal(raw, &s); err != nil { + return health.Unhealthy("the watch's verdict cannot be read") + } + if age := now.Sub(s.At); age > StatusStale { + return health.Unhealthy(fmt.Sprintf("the watch has not looked for %s (its last verdict: %s)", age.Round(time.Second), s.Why)) + } + // Read through the SDK's reader, so a wait it cannot check is never excused. + a, err := health.Read(mustJSON(s.Answer)) + if err != nil { + return health.Unhealthy("the watch's verdict cannot be read: " + err.Error()) + } + return a +} + +func mustJSON(v any) []byte { + raw, _ := json.Marshal(v) + return raw +} + +// watchOnce is one look: what the watch reads, judged. +func watchOnce(ctx context.Context, env Env, open func(context.Context) (Store, func(), error), now time.Time) health.Answer { + accounts, accountsErr := ReadAccounts(env.Accounts) + var readyErr error + states := map[string]AccountState{} + st, done, err := open(ctx) + if err != nil { + readyErr = err + } else { + defer done() + if readyErr = st.Ready(ctx); readyErr == nil { + if states, err = st.States(ctx); err != nil { + readyErr = err + } + } + } + return Judge(accounts, accountsErr, readyErr, func(a Account) bool { return SecretGiven(env.Secrets, a) }, states, now) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/health_test.go b/modules/mesh-mail/cmd/mesh-mail/health_test.go new file mode 100644 index 00000000..44252013 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/health_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +var judgedAt = time.Date(2026, 10, 14, 18, 0, 0, 0, time.UTC) + +func given(names ...string) func(Account) bool { + return func(a Account) bool { return contains(names, a.Name) } +} + +// Before the password is given, mail_health answers waits, naming the secret, which the node-engine reads as a wait +// the controller can excuse (ADR 0283 §1, §3): the family mail-credential-* is declared issued outside the mesh. +func TestItWaitsNamingTheSecretUntilThePasswordIsGiven(t *testing.T) { + a := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt) + if a.State != health.StateWaits || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-mesh" || a.Healthy { + t.Fatalf("%+v", a) + } + read, err := health.Read(mustJSON(a)) + if err != nil || read.State != health.StateWaits { + t.Fatalf("the node-engine reads it as %+v (%v)", read, err) + } + if strings.Contains(a.Waits[0].What, "@") || strings.Contains(a.Why, "@") { + t.Fatalf("the wait says an address: %+v", a) + } +} + +// Unhealthy, naming the account and why, when its connection is down or its server silent for five minutes. +func TestItIsUnhealthyWhenAnAccountIsDownOrSilent(t *testing.T) { + acc := []Account{testAccount()} + down := map[string]AccountState{"mesh": {Account: "mesh", State: AccountFailing, Why: "its server mail.example cannot be reached", Said: judgedAt}} + if a := Judge(acc, nil, nil, given("mesh"), down, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "the account mesh is not watched: its server") { + t.Fatalf("down: %+v", a) + } + silent := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Heard: judgedAt.Add(-SilentAfter - time.Second), Said: judgedAt}} + if a := Judge(acc, nil, nil, given("mesh"), silent, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "IDLE on the inbox is silent") { + t.Fatalf("silent: %+v", a) + } + fine := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt.Add(-time.Minute), Said: judgedAt}} + if a := Judge(acc, nil, nil, given("mesh"), fine, judgedAt); a.State != health.StateHealthy || !a.Healthy { + t.Fatalf("fine: %+v", a) + } + if a := Judge(acc, nil, errors.New("the schema is behind"), given("mesh"), fine, judgedAt); a.State != health.StateUnhealthy { + t.Fatalf("an index that cannot serve: %+v", a) + } + if a := Judge(nil, errors.New("the accounts setting is refused"), nil, given(), nil, judgedAt); a.State != health.StateUnhealthy { + t.Fatalf("a setting it cannot read: %+v", a) + } + // A fault beside a wait is unhealthy and says both (ADR 0283 §1). + two := []Account{testAccount(), {Name: "gmail", Kind: KindIMAP, Address: "x@example.com", Incoming: Server{Host: "h", Port: 993, Security: "tls"}}} + a := Judge(two, nil, nil, given("mesh"), down, judgedAt) + if a.State != health.StateUnhealthy || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-gmail" || !strings.Contains(a.Why, "mail-credential-gmail") { + t.Fatalf("both: %+v", a) + } + // A password just given is signing in for a while; not for ever. + signing := map[string]AccountState{"mesh": {Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-time.Minute)}} + if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateHealthy { + t.Fatalf("signing in: %+v", a) + } + signing["mesh"] = AccountState{Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-SignInGrace - time.Minute)} + if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateUnhealthy { + t.Fatalf("never signed in: %+v", a) + } + if a := Judge(nil, nil, nil, given(), nil, judgedAt); a.State != health.StateHealthy || !strings.Contains(a.Why, "nothing is watched") { + t.Fatalf("no account: %+v", a) + } +} + +// The tool reads the watch's verdict, and says when the watch stopped looking. +func TestTheHealthToolReadsTheWatchsVerdictAndSaysWhenItIsStale(t *testing.T) { + path := filepath.Join(t.TempDir(), "health.json") + if a := ReadStatus(path, judgedAt); a.State != health.StateUnhealthy { + t.Fatalf("no verdict: %+v", a) + } + waits := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt) + if err := writeStatus(path, Status{Answer: waits, At: judgedAt}); err != nil { + t.Fatal(err) + } + if a := ReadStatus(path, judgedAt.Add(time.Minute)); a.State != health.StateWaits || a.Waits[0].Secret != "mail-credential-mesh" { + t.Fatalf("%+v", a) + } + if a := ReadStatus(path, judgedAt.Add(StatusStale+time.Second)); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "has not looked") { + t.Fatalf("stale: %+v", a) + } +} + +// One look of the watch, end to end over the files the mesh writes: the accounts setting, the secrets directory and +// the index. +func TestTheWatchJudgesFromTheFilesAndTheIndex(t *testing.T) { + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir} + writeFile(t, env.Accounts, stageOne) + st := newMemStore() + open := func(context.Context) (Store, func(), error) { return st, func() {}, nil } + if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateWaits { + t.Fatalf("%+v", a) + } + giveSecret(t, dir, Account{Name: "mesh"}, "pw") + _ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountCurrent, Heard: judgedAt, Said: judgedAt}) + if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateHealthy { + t.Fatalf("%+v", a) + } + raw, _ := json.Marshal(Status{Answer: health.Healthy("x"), At: judgedAt}) + if !strings.Contains(string(raw), `"state":"healthy"`) || !strings.Contains(string(raw), `"at":`) { + t.Fatalf("the status file's shape: %s", raw) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/imap.go b/modules/mesh-mail/cmd/mesh-mail/imap.go new file mode 100644 index 00000000..5488e535 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/imap.go @@ -0,0 +1,570 @@ +package main + +// The adapter for an account of the kind `imap` (novox/hq ADR 0359 §1, §2, §5). For each account it keeps one +// connection: IDLE on the inbox, and every PollEvery a look at every other folder. Each look compares what the server +// holds with the index and says each difference as an event: a message added, its flags changed, or removed. The first +// look at a folder indexes what is there without saying it — what the bus is told is what changed since the mesh +// started watching, never the history of a mailbox. +// +// **It only reads.** Every folder is opened read-only (EXAMINE), and every message is fetched with BODY.PEEK, so +// watching never marks a message seen. The client is used through reader alone, which has no command that flags, +// moves, copies, deletes, appends or sends (§5); a test holds the code to that. + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// How the keeper paces itself. IdleCycle is how long one IDLE lasts before it is ended and begun again, which proves +// the server still answers; SilentAfter is how long without an answer makes the account unhealthy (§6). +const ( + IdleCycle = 2 * time.Minute + PollEvery = 5 * time.Minute + SilentAfter = 5 * time.Minute + RetryFirst = 30 * time.Second + RetryMost = 5 * time.Minute + FetchBatch = 25 + // BatchBytes bounds the mail one fetch of bodies holds; SizeBatch is how many sizes one fetch asks. + BatchBytes = 64 << 20 + SizeBatch = 500 +) + +// Batches cuts the uids, in order, into batches of at most most messages whose sizes add up to at most limit bytes. A +// message larger than limit is a batch of its own. A size not known counts as nothing: the server listed the message, +// and the fetch says what it holds. +func Batches(uids []uint32, sizes map[uint32]int64, limit int64, most int) [][]uint32 { + var out [][]uint32 + var cur []uint32 + var bytes int64 + for _, u := range uids { + size := sizes[u] + if len(cur) > 0 && (len(cur) >= most || bytes+size > limit) { + out = append(out, cur) + cur, bytes = nil, 0 + } + cur = append(cur, u) + bytes += size + } + if len(cur) > 0 { + out = append(out, cur) + } + return out +} + +func uidSet(uids []uint32) imap.UIDSet { + set := make([]imap.UID, len(uids)) + for i, u := range uids { + set[i] = imap.UID(u) + } + return imap.UIDSetNum(set...) +} + +func orBytes(n, def int64) int64 { + if n > 0 { + return n + } + return def +} + +// reader is the part of an IMAP client the keeper uses: nothing in it changes a mailbox. +type reader interface { + Login(username, password string) *imapclient.Command + List(ref, pattern string, options *imap.ListOptions) *imapclient.ListCommand + Select(mailbox string, options *imap.SelectOptions) *imapclient.SelectCommand + Fetch(numSet imap.NumSet, options *imap.FetchOptions) *imapclient.FetchCommand + Idle() (*imapclient.IdleCommand, error) + Caps() imap.CapSet + Closed() <-chan struct{} + Logout() *imapclient.Command + Close() error +} + +// Dialer opens a connection to an account's server, handing what the server says unasked to the handler. +type Dialer func(a Account, handler *imapclient.UnilateralDataHandler) (reader, error) + +// DialServer is the dialer for the mesh: TLS from the first byte, or STARTTLS before signing in, as the account says. +func DialServer(a Account, handler *imapclient.UnilateralDataHandler) (reader, error) { + opts := &imapclient.Options{UnilateralDataHandler: handler, TLSConfig: &tls.Config{ServerName: a.Incoming.Host, MinVersion: tls.VersionTLS12}} + var c *imapclient.Client + var err error + switch a.Incoming.Security { + case "tls": + c, err = imapclient.DialTLS(a.Incoming.Address(), opts) + case "starttls": + c, err = imapclient.DialStartTLS(a.Incoming.Address(), opts) + default: + err = fmt.Errorf("the account %s's security %q signs in over nothing this version speaks", a.Name, a.Incoming.Security) + } + if err != nil { + return nil, err + } + return c, nil +} + +// Keeper watches the accounts and keeps the index. +type Keeper struct { + Store Store + Objects Objects + Dial Dialer + SecretsDir string + Logf func(format string, a ...any) + Now func() time.Time + + // Paces, for the tests; the constants above when zero. + IdleCycle, PollEvery, RetryFirst time.Duration + // BatchBytes, for the tests; the constant when zero. + BatchBytes int64 +} + +func (k *Keeper) now() time.Time { + if k.Now != nil { + return k.Now() + } + return time.Now() +} + +func (k *Keeper) logf(format string, a ...any) { + if k.Logf != nil { + k.Logf(format, a...) + } +} + +func orDefault(d, def time.Duration) time.Duration { + if d > 0 { + return d + } + return def +} + +// Run watches every account until ctx ends. +func (k *Keeper) Run(ctx context.Context, accounts []Account) { + var wg sync.WaitGroup + for _, a := range accounts { + wg.Add(1) + go func(a Account) { + defer wg.Done() + k.Watch(ctx, a) + }(a) + } + wg.Wait() +} + +func (k *Keeper) say(ctx context.Context, a Account, state, why string, heard, synced time.Time) { + if err := k.Store.SetState(ctx, AccountState{Account: a.Name, State: state, Why: why, Heard: heard, Synced: synced, Said: k.now()}); err != nil { + k.logf("[mesh-mail] the account %s's state could not be kept: %v", a.Name, err) + } +} + +// Watch keeps one account until ctx ends: waits while its secret is not given, signs in, watches, and on any fault +// says it, waits a little longer each time, and begins again. +func (k *Keeper) Watch(ctx context.Context, a Account) { + retry := orDefault(k.RetryFirst, RetryFirst) + var heard, synced time.Time + for ctx.Err() == nil { + pw, given, err := ReadSecret(k.SecretsDir, a) + switch { + case err != nil: + k.say(ctx, a, AccountFailing, "its password was given and cannot be read: "+err.Error(), heard, synced) + case !given: + k.say(ctx, a, AccountWaits, "its password ("+a.Secret()+") has not been given", heard, synced) + default: + s := &session{k: k, a: a, wake: make(chan struct{}, 1), heard: heard, synced: synced} + err = s.run(ctx, pw) + heard, synced = s.heard, s.synced + if ctx.Err() != nil { + return + } + k.say(ctx, a, AccountFailing, err.Error(), heard, synced) + k.logf("[mesh-mail] the account %s: %v; trying again in %s", a.Name, err, retry) + } + select { + case <-ctx.Done(): + return + case <-time.After(retry): + } + if given && err != nil { + retry = min(retry*2, RetryMost) + } else { + retry = orDefault(k.RetryFirst, RetryFirst) + } + } +} + +// session is one connection to one account's server. +type session struct { + k *Keeper + a Account + c reader + wake chan struct{} + heard time.Time + synced time.Time + // state and why are what the session last said of the account, and told when it said it. + state, why string + told time.Time +} + +// answered is the server having answered: kept, and told at least once a minute, so a long first look at a large +// mailbox still shows the account alive to the health watch. +func (s *session) answered() { + s.heard = s.k.now() + if s.state != "" && s.heard.Sub(s.told) >= time.Minute { + s.tell(s.state, s.why) + } +} + +// tell says the account's state. +func (s *session) tell(state, why string) { + s.state, s.why, s.told = state, why, s.k.now() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + s.k.say(ctx, s.a, state, why, s.heard, s.synced) +} + +func (s *session) poke() { + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *session) run(ctx context.Context, password string) error { + handler := &imapclient.UnilateralDataHandler{ + Expunge: func(uint32) { s.poke() }, + Mailbox: func(*imapclient.UnilateralDataMailbox) { s.poke() }, + Fetch: func(*imapclient.FetchMessageData) { s.poke() }, + } + c, err := s.k.Dial(s.a, handler) + if err != nil { + return fmt.Errorf("its server %s cannot be reached: %v", s.a.Incoming.Host, err) + } + s.c = c + defer c.Close() + if err := c.Login(s.a.SignInAs(), password).Wait(); err != nil { + var said *imap.Error + if errors.As(err, &said) { + return fmt.Errorf("its server refused the sign-in (%s): its password may be wrong, and is given again at the desk", strings.TrimSpace(said.Text)) + } + return fmt.Errorf("signing in failed: %v", err) + } + s.answered() + s.tell(AccountSyncing, "signed in; looking at its folders") + folders, err := s.folders() + if err != nil { + return err + } + idles := c.Caps().Has(imap.CapIdle) || c.Caps().Has(imap.CapIMAP4rev2) + var polled time.Time + for ctx.Err() == nil { + if polled.IsZero() || s.k.now().Sub(polled) >= orDefault(s.k.PollEvery, PollEvery) { + for _, f := range folders { + if err := s.k.syncFolder(ctx, c, s.a, f, s.answered); err != nil { + return err + } + } + polled = s.k.now() + } + // The inbox last, so it is the folder selected while the session idles. + if err := s.k.syncFolder(ctx, c, s.a, "INBOX", s.answered); err != nil { + return err + } + s.synced = s.k.now() + s.tell(AccountCurrent, fmt.Sprintf("watching the inbox and %d other folder(s)", len(folders))) + if err := s.wait(ctx, idles); err != nil { + return err + } + } + _ = c.Logout().Wait() + return ctx.Err() +} + +// wait is one IDLE on the inbox, ended by what the server says unasked, by the cycle's end or by ctx. Ending it waits +// for the server's answer, which is what proves the connection alive. +func (s *session) wait(ctx context.Context, idles bool) error { + cycle := time.NewTimer(orDefault(s.k.IdleCycle, IdleCycle)) + defer cycle.Stop() + if !idles { + select { + case <-ctx.Done(): + case <-cycle.C: + case <-s.c.Closed(): + return errors.New("its server closed the connection") + } + return nil + } + idle, err := s.c.Idle() + if err != nil { + return fmt.Errorf("its server refused to IDLE on the inbox: %v", err) + } + select { + case <-ctx.Done(): + case <-s.wake: + case <-cycle.C: + case <-s.c.Closed(): + } + if err := idle.Close(); err != nil { + return fmt.Errorf("its IDLE on the inbox ended badly: %v", err) + } + if err := idle.Wait(); err != nil { + return fmt.Errorf("its IDLE on the inbox ended badly: %v", err) + } + s.answered() + return nil +} + +// folders is what is watched besides the inbox: the account's list, or every folder the server can open. +func (s *session) folders() ([]string, error) { + if len(s.a.Folders) > 0 { + return append([]string(nil), s.a.Folders...), nil + } + listed, err := s.c.List("", "*", nil).Collect() + if err != nil { + return nil, fmt.Errorf("its folders cannot be listed: %v", err) + } + s.answered() + var out []string + for _, l := range listed { + if strings.EqualFold(l.Mailbox, "INBOX") || hasAttr(l.Attrs, imap.MailboxAttrNoSelect) || hasAttr(l.Attrs, imap.MailboxAttrNonExistent) { + continue + } + out = append(out, l.Mailbox) + } + sort.Strings(out) + return out, nil +} + +func hasAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool { + for _, a := range attrs { + if strings.EqualFold(string(a), string(want)) { + return true + } + } + return false +} + +// syncFolder brings the index of one folder to what the server holds, and says each difference, unless this is the +// first look at the folder. +func (k *Keeper) syncFolder(ctx context.Context, c reader, a Account, folder string, answered func()) error { + sel, err := c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait() + if err != nil { + return fmt.Errorf("the folder %s cannot be opened: %v", folder, err) + } + answered() + known, seen, err := k.Store.Folder(ctx, a.Name, folder) + if err != nil { + return err + } + if seen && known.UIDValidity != sel.UIDValidity { + k.logf("[mesh-mail] the account %s's folder %s was renumbered by its server; it is indexed afresh", a.Name, folder) + if err := k.Store.DropFolder(ctx, a.Name, folder); err != nil { + return err + } + seen = false + } + server := map[uint32][]string{} + if sel.NumMessages > 0 { + all, err := c.Fetch(imap.UIDSet{{Start: 1, Stop: 0}}, &imap.FetchOptions{UID: true, Flags: true}).Collect() + if err != nil { + return fmt.Errorf("the folder %s's messages cannot be listed: %v", folder, err) + } + answered() + for _, m := range all { + server[uint32(m.UID)] = flagStrings(m.Flags) + } + } + index, err := k.Store.Flags(ctx, a.Name, folder) + if err != nil { + return err + } + say := func(e *Event) *Event { + if seen { + return e + } + return nil + } + // Removed: in the index, no longer on the server. + for uid := range index { + if _, still := server[uid]; still { + continue + } + m, _, err := k.Store.Get(ctx, a.Name, folder, uid) + if err != nil { + return err + } + if m.UID == 0 { + m = Message{Account: a.Name, Folder: folder, UID: uid, UIDValidity: sel.UIDValidity} + } + if err := k.Store.Remove(ctx, a.Name, folder, uid, say(removedEvent(m))); err != nil { + return err + } + } + // Changed: on both, with other flags. + var added []uint32 + for uid, flags := range server { + before, indexed := index[uid] + if !indexed { + added = append(added, uid) + continue + } + if sameStrings(before, flags) { + continue + } + m, _, err := k.Store.Get(ctx, a.Name, folder, uid) + if err != nil { + return err + } + m.Flags = flags + if err := k.Store.SetFlags(ctx, a.Name, folder, uid, flags, say(changedEvent(m, before, flags))); err != nil { + return err + } + } + // Added: on the server, not yet in the index. Their sizes first, then their bodies in batches bounded by bytes as + // well as by count, so one batch never holds more than BatchBytes of mail in memory; a message larger than that + // is fetched alone, and said. Fetched without marking them seen. + sort.Slice(added, func(i, j int) bool { return added[i] < added[j] }) + sizes := map[uint32]int64{} + for start := 0; start < len(added); start += SizeBatch { + part := added[start:min(start+SizeBatch, len(added))] + got, err := c.Fetch(uidSet(part), &imap.FetchOptions{UID: true, RFC822Size: true}).Collect() + if err != nil { + return fmt.Errorf("the folder %s's new messages cannot be measured: %v", folder, err) + } + answered() + for _, m := range got { + sizes[uint32(m.UID)] = m.RFC822Size + } + } + cap := orBytes(k.BatchBytes, BatchBytes) + for _, batch := range Batches(added, sizes, cap, FetchBatch) { + if len(batch) == 1 && sizes[batch[0]] > cap { + k.logf("[mesh-mail] the account %s's message %d in %s is %d bytes, more than a batch holds; it is fetched alone", + a.Name, batch[0], folder, sizes[batch[0]]) + } + msgs, err := c.Fetch(uidSet(batch), &imap.FetchOptions{ + UID: true, Flags: true, Envelope: true, RFC822Size: true, InternalDate: true, + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, + }).Collect() + if err != nil { + return fmt.Errorf("the folder %s's new messages cannot be fetched: %v", folder, err) + } + answered() + for _, fm := range msgs { + m, err := k.index(ctx, a, folder, sel.UIDValidity, fm) + if err != nil { + return err + } + if err := k.Store.Add(ctx, m, say(addedEvent(m))); err != nil { + return err + } + } + } + return k.Store.SetFolder(ctx, Folder{Account: a.Name, Name: folder, UIDValidity: sel.UIDValidity, Messages: len(server), Synced: k.now()}) +} + +// index is a fetched message as the index keeps it: its metadata, its text, and its attachments kept in the store. +func (k *Keeper) index(ctx context.Context, a Account, folder string, validity uint32, fm *imapclient.FetchMessageBuffer) (Message, error) { + m := Message{Account: a.Name, Folder: folder, UIDValidity: validity, UID: uint32(fm.UID), Size: fm.RFC822Size, + Flags: flagStrings(fm.Flags), Date: fm.InternalDate.UTC(), From: []Person{}, To: []Person{}, Attachments: []AttachmentRef{}} + if env := fm.Envelope; env != nil { + m.MessageID, m.Subject = env.MessageID, env.Subject + if len(env.InReplyTo) > 0 { + m.InReplyTo = env.InReplyTo[0] + } + if !env.Date.IsZero() { + m.Date = env.Date.UTC() + } + m.From, m.To, m.Cc = people(env.From), people(env.To), people(env.Cc) + } + var raw []byte + if len(fm.BodySection) > 0 { + raw = fm.BodySection[0].Bytes + } + parsed, err := ParseMessage(raw) + if err != nil { + k.logf("[mesh-mail] the account %s's message %d in %s is read in part: %v", a.Name, m.UID, folder, err) + } + m.Body = parsed.Text + for _, p := range parsed.Attachments { + ref, err := Keep(ctx, k.Objects, p.Data, p.Type, p.Name) + if err != nil { + return m, err + } + m.Attachments = append(m.Attachments, ref) + } + return m, nil +} + +func people(as []imap.Address) []Person { + out := []Person{} + for _, a := range as { + if addr := a.Addr(); addr != "" { + out = append(out, Person{Name: a.Name, Address: addr}) + } + } + return out +} + +func flagStrings(fs []imap.Flag) []string { + out := make([]string, 0, len(fs)) + for _, f := range fs { + if strings.EqualFold(string(f), `\Recent`) { + continue + } + out = append(out, string(f)) + } + sort.Strings(out) + return out +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// ---- the events --------------------------------------------------------------------------------------------------- +// EventKey and Changes, the event's shape, now live in mailevent.go of their own (issue 800 part A). + +// metadata is what an event says of a message: never its body, never an attachment's bytes (ADR 0359 §2). +func metadata(m Message) map[string]any { + atts := make([]map[string]any, 0, len(m.Attachments)) + for _, r := range m.Attachments { + atts = append(atts, map[string]any{"bucket": r.Bucket, "key": r.Key, "size": r.Size, "type": r.Type, "name": r.Name}) + } + return map[string]any{ + "account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity, + "message-id": m.MessageID, "in-reply-to": m.InReplyTo, "date": m.Date.UTC().Format(time.RFC3339), + "from": m.From, "to": m.To, "cc": m.Cc, "subject": m.Subject, "size": m.Size, "flags": m.Flags, + "attachments": atts, + } +} + +func addedEvent(m Message) *Event { + return &Event{Key: EventKey(m.Account, "added"), Body: metadata(m)} +} + +func changedEvent(m Message, before, after []string) *Event { + body := metadata(m) + body["changes"] = map[string]any{"flags": map[string]any{"before": before, "after": after}} + return &Event{Key: EventKey(m.Account, "changed"), Body: body} +} + +func removedEvent(m Message) *Event { + return &Event{Key: EventKey(m.Account, "removed"), Body: map[string]any{ + "account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity, "message-id": m.MessageID, + }} +} diff --git a/modules/mesh-mail/cmd/mesh-mail/imap_test.go b/modules/mesh-mail/cmd/mesh-mail/imap_test.go new file mode 100644 index 00000000..47d9d53d --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/imap_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/emersion/go-imap/v2" +) + +// keeping starts a keeper on one account of the test server, its paces short, and stops it when the test ends. +func keeping(t *testing.T, s *mailServer, st Store, objects Objects, secrets string) { + t.Helper() + k := &Keeper{Store: st, Objects: objects, Dial: s.dialer(), SecretsDir: secrets, Logf: t.Logf, + IdleCycle: 300 * time.Millisecond, PollEvery: 400 * time.Millisecond, RetryFirst: 100 * time.Millisecond} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { k.Watch(ctx, testAccount()); close(done) }() + t.Cleanup(func() { cancel(); <-done }) +} + +func stateOf(st Store, account string) AccountState { + states, _ := st.States(context.Background()) + return states[account] +} + +// float64OrUint reads a uid from an event body whether it came from memory (uint32) or from postgres (float64). +type float64OrUint struct{ v any } + +func (f float64OrUint) value() uint32 { + switch n := f.v.(type) { + case uint32: + return n + case float64: + return uint32(n) + } + return 0 +} + +func uidOf(e Event) uint32 { return float64OrUint{e.Body["uid"]}.value() } + +func eventsNamed(st Store, key string) []Event { + evs, _ := st.Pending(context.Background(), 1000) + var out []Event + for _, e := range evs { + if e.Key == key { + out = append(out, e) + } + } + return out +} + +func describe(st Store) string { + evs, _ := st.Pending(context.Background(), 1000) + var keys []string + for _, e := range evs { + keys = append(keys, e.Key) + } + s := stateOf(st, "mesh") + return "state " + s.State + " (" + s.Why + "); events " + strings.Join(keys, ", ") +} + +// The whole watch, over a real IMAP conversation (ADR 0359 §2, §3, §5): the first look indexes what is there and says +// nothing; then a new mail in the inbox is said within moments of its delivery, through IDLE, with its metadata and +// its attachment by reference, never its body; its flags changed are said; its removal is said; a mail in another +// folder is said at the next poll; and watching never marks a message seen. +func TestTheKeeperSaysEachChangeOnTheBusAndOnlyReads(t *testing.T) { + eachStore(t, func(t *testing.T, st Store) { + s := newMailServer(t) + op := s.operator(t) + deliver(t, op, "INBOX", plainMail("before the watch", "an old letter")) + deliver(t, op, "Archive", plainMail("archived long ago", "an older letter")) + secrets := t.TempDir() + giveSecret(t, secrets, testAccount(), testPassword) + objects := newMemObjects() + keeping(t, s, st, objects, secrets) + + eventually(t, 10*time.Second, "the first look", func() (bool, string) { + return stateOf(st, "mesh").State == AccountCurrent, describe(st) + }) + ctx := context.Background() + if hits, _ := st.Search(ctx, Query{Text: "letter", Accounts: []string{"mesh"}}); len(hits) != 2 { + t.Fatalf("the first look indexed %d messages, not 2", len(hits)) + } + if evs, _ := st.Pending(ctx, 100); len(evs) != 0 { + t.Fatalf("the first look said %d event(s): the history of a mailbox is not news", len(evs)) + } + + // A new mail with an attachment, delivered while the keeper idles on the inbox. + deliver(t, op, "INBOX", attachedMail("the invoice")) + var added Event + eventually(t, 5*time.Second, "the new mail said", func() (bool, string) { + evs := eventsNamed(st, "mail.mesh.message.added") + if len(evs) == 1 { + added = evs[0] + } + return len(evs) == 1, describe(st) + }) + body := added.Body + if body["subject"] != "the invoice" || body["folder"] != "INBOX" || body["account"] != "mesh" || body["message-id"] != "att@example.org" { + t.Fatalf("the event says %v", body) + } + raw, _ := json.Marshal(body) + for _, never := range []string{"Please find", "dGhlIGludm9pY2Ug", attachmentData} { + if strings.Contains(string(raw), never) { + t.Fatalf("the event carries the body or an attachment's bytes (%q): %s", never, raw) + } + } + sum := sha256.Sum256([]byte(attachmentData)) + key := hex.EncodeToString(sum[:]) + if !strings.Contains(string(raw), `"key":"`+key+`"`) || !strings.Contains(string(raw), `"name":"invoice.pdf"`) { + t.Fatalf("the event does not name the attachment by its SHA-256 %s: %s", key, raw) + } + if kept, _ := objects.Get(ctx, key); string(kept) != attachmentData { + t.Fatalf("the attachment store keeps %q under %s", kept, key) + } + uid := uidOf(added) + m, ok, _ := st.Get(ctx, "mesh", "INBOX", uid) + if !ok || !strings.Contains(m.Body, "Please find the invoice attached.") { + t.Fatalf("the index keeps the text for mail_read: %+v", m) + } + + // Flagged by the operator elsewhere: said as changed, with the flags before and after. + if _, err := op.Select("INBOX", nil).Wait(); err != nil { + t.Fatal(err) + } + if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagFlagged}, Silent: true}, nil).Close(); err != nil { + t.Fatal(err) + } + eventually(t, 5*time.Second, "the flag said", func() (bool, string) { + evs := eventsNamed(st, "mail.mesh.message.changed") + if len(evs) != 1 { + return false, describe(st) + } + raw, _ := json.Marshal(evs[0].Body["changes"]) + return strings.Contains(string(raw), `"after":["\\Flagged"]`), string(raw) + }) + + // Never marked seen by the watching: the message is still unseen on the server. + fetched, err := op.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{Flags: true, UID: true}).Collect() + if err != nil || len(fetched) != 1 { + t.Fatalf("%v %v", fetched, err) + } + for _, f := range fetched[0].Flags { + if f == imap.FlagSeen { + t.Fatal("watching marked the message seen") + } + } + + // Deleted by the operator elsewhere: said as removed. + if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil { + t.Fatal(err) + } + if err := op.Expunge().Close(); err != nil { + t.Fatal(err) + } + eventually(t, 5*time.Second, "the removal said", func() (bool, string) { + evs := eventsNamed(st, "mail.mesh.message.removed") + return len(evs) == 1 && uidOf(evs[0]) == uid && evs[0].Body["message-id"] == "att@example.org", describe(st) + }) + if _, ok, _ := st.Get(ctx, "mesh", "INBOX", uid); ok { + t.Fatal("the removed message is still indexed") + } + + // A mail in another folder is said at the next poll. + deliver(t, op, "Archive", plainMail("filed away", "a letter filed")) + eventually(t, 5*time.Second, "the archived mail said", func() (bool, string) { + for _, e := range eventsNamed(st, "mail.mesh.message.added") { + if e.Body["folder"] == "Archive" && e.Body["subject"] == "filed away" { + return true, "" + } + } + return false, describe(st) + }) + if s := stateOf(st, "mesh"); s.State != AccountCurrent || time.Since(s.Heard) > time.Minute { + t.Fatalf("the account is %+v", s) + } + }) +} + +// No password given: the account waits, and nothing is dialled. A wrong one: the account fails, saying the server +// refused the sign-in. Given later: it is picked up without a restart. +func TestTheKeeperWaitsForThePasswordAndSaysARefusal(t *testing.T) { + s := newMailServer(t) + st := newMemStore() + secrets := t.TempDir() + keeping(t, s, st, newMemObjects(), secrets) + eventually(t, 5*time.Second, "the wait", func() (bool, string) { + got := stateOf(st, "mesh") + return got.State == AccountWaits && strings.Contains(got.Why, "mail-credential-mesh"), describe(st) + }) + giveSecret(t, secrets, testAccount(), "not-the-password") + eventually(t, 5*time.Second, "the refusal", func() (bool, string) { + got := stateOf(st, "mesh") + return got.State == AccountFailing && strings.Contains(got.Why, "refused the sign-in"), describe(st) + }) + giveSecret(t, secrets, testAccount(), testPassword) + eventually(t, 10*time.Second, "the sign-in", func() (bool, string) { + return stateOf(st, "mesh").State == AccountCurrent, describe(st) + }) +} + +// It only reads a mailbox (ADR 0359 §5): no code of the module, but its tests, issues a command that flags, moves, +// copies, deletes, appends, creates, renames or otherwise writes to one over IMAP, and every folder is opened +// read-only and every body peeked. mail_send is the one exception the operator's own Approve carves out (ADR 0361 +// §2): it never touches a mailbox either — it speaks SMTP, in smtp.go alone, to the account's outgoing server, and +// only once the operator approved it (send.go). +func TestNothingButTheTestsWritesToAMailbox(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" { + // Neither touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP + // to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361 §2). + continue + } + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, m := range writes.FindAllSubmatch(raw, -1) { + // os is a file of the module's own, never a mailbox; stdio is the bus SDK (main.go's hear subscribes to + // the router's own decided subject, exactly as mesh-knowledge does, novox/hq issue 790 review round 1) + // — its Subscribe has nothing to do with an IMAP mailbox's. + if string(m[1]) != "os" && string(m[1]) != "stdio" { + t.Errorf("%s calls %s, which writes to a mailbox", f, m[0]) + } + } + if strings.Contains(string(raw), "net/smtp") { + t.Errorf("%s speaks SMTP: only smtp.go does, and only on the operator's Approve (ADR 0361 §2)", f) + } + } + src, _ := os.ReadFile("imap.go") + if !strings.Contains(string(src), "&imap.SelectOptions{ReadOnly: true}") || !strings.Contains(string(src), "{{Peek: true}}") { + t.Fatal("folders are not opened read-only, or bodies not peeked") + } +} + +// A fetch of bodies is bounded by bytes (issue 733): batches cut by size and by count, and a message larger than a +// batch holds alone. +func TestBatchesAreBoundedByBytesAndALargerMessageGoesAlone(t *testing.T) { + sizes := map[uint32]int64{1: 40, 2: 40, 3: 500, 4: 10, 5: 10, 6: 10} + got := Batches([]uint32{1, 2, 3, 4, 5, 6}, sizes, 100, 2) + want := [][]uint32{{1, 2}, {3}, {4, 5}, {6}} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%v, not %v", got, want) + } + if got := Batches(nil, nil, 100, 25); len(got) != 0 { + t.Fatalf("%v", got) + } +} + +// Over a real conversation: with a cap of a few hundred bytes, a large mail is fetched alone and said so, and every +// mail is still indexed and said. +func TestTheKeeperFetchesUnderAByteCap(t *testing.T) { + s := newMailServer(t) + op := s.operator(t) + st := newMemStore() + secrets := t.TempDir() + giveSecret(t, secrets, testAccount(), testPassword) + var mu sync.Mutex + var said []string + k := &Keeper{Store: st, Objects: newMemObjects(), Dial: s.dialer(), SecretsDir: secrets, + Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() }, + IdleCycle: 300 * time.Millisecond, PollEvery: 400 * time.Millisecond, RetryFirst: 100 * time.Millisecond, BatchBytes: 600} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { k.Watch(ctx, testAccount()); close(done) }() + t.Cleanup(func() { cancel(); <-done }) + eventually(t, 10*time.Second, "the first look", func() (bool, string) { return stateOf(st, "mesh").State == AccountCurrent, describe(st) }) + deliver(t, op, "INBOX", plainMail("small one", "short")) + deliver(t, op, "INBOX", plainMail("large one", strings.Repeat("a long letter ", 200))) + deliver(t, op, "INBOX", plainMail("small two", "short")) + eventually(t, 5*time.Second, "three mails said", func() (bool, string) { + return len(eventsNamed(st, "mail.mesh.message.added")) == 3, describe(st) + }) + mu.Lock() + defer mu.Unlock() + alone := 0 + for _, line := range said { + if strings.Contains(line, "fetched alone") { + alone++ + } + } + if alone != 1 { + t.Fatalf("said %d message(s) fetched alone: %v", alone, said) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/kit_test.go b/modules/mesh-mail/cmd/mesh-mail/kit_test.go new file mode 100644 index 00000000..e7308661 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/kit_test.go @@ -0,0 +1,252 @@ +package main + +// What the tests stand on: an IMAP server in this process (go-imap's own in-memory server, speaking the protocol over +// a real socket), the index in memory always and on a real postgres when one can be had (MESH_TEST_POSTGRES, or a +// private server started here from the machine's initdb on a socket of its own, listening on no port), and the +// attachment store in memory. + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-imap/v2/imapserver/imapmemserver" +) + +func TestMain(m *testing.M) { + code := m.Run() + stopPostgres() + os.Exit(code) +} + +// ---- the IMAP server -------------------------------------------------------------------------------------------- + +const ( + testUser = "operator@example.org" + testPassword = "the-password" +) + +type mailServer struct { + addr string + srv *imapserver.Server +} + +// newMailServer is a server holding one user with an inbox and an Archive folder. +func newMailServer(t *testing.T) *mailServer { + t.Helper() + mem := imapmemserver.New() + user := imapmemserver.NewUser(testUser, testPassword) + for _, f := range []string{"INBOX", "Archive"} { + if err := user.Create(f, nil); err != nil { + t.Fatal(err) + } + } + mem.AddUser(user) + srv := imapserver.New(&imapserver.Options{ + NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return mem.NewSession(), nil, nil + }, + InsecureAuth: true, + Caps: imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}}, + }) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + return &mailServer{addr: ln.Addr().String(), srv: srv} +} + +// dialer reaches the test server without TLS, which only a test does: the mesh's dialer signs in over TLS alone. +func (s *mailServer) dialer() Dialer { + return func(_ Account, h *imapclient.UnilateralDataHandler) (reader, error) { + c, err := imapclient.DialInsecure(s.addr, &imapclient.Options{UnilateralDataHandler: h}) + if err != nil { + return nil, err + } + return c, nil + } +} + +// operator is a second client, as the operator's mail program or another sender is: it appends, flags and deletes. +func (s *mailServer) operator(t *testing.T) *imapclient.Client { + t.Helper() + c, err := imapclient.DialInsecure(s.addr, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + if err := c.Login(testUser, testPassword).Wait(); err != nil { + t.Fatal(err) + } + return c +} + +func deliver(t *testing.T, c *imapclient.Client, folder, raw string) { + t.Helper() + raw = strings.ReplaceAll(raw, "\n", "\r\n") + cmd := c.Append(folder, int64(len(raw)), nil) + if _, err := cmd.Write([]byte(raw)); err != nil { + t.Fatal(err) + } + if err := cmd.Close(); err != nil { + t.Fatal(err) + } + if _, err := cmd.Wait(); err != nil { + t.Fatal(err) + } +} + +func plainMail(subject, body string) string { + return "From: Alice \nTo: Operator \nSubject: " + subject + + "\nMessage-Id: <" + strings.ReplaceAll(subject, " ", "-") + "@example.org>\nDate: Wed, 14 Oct 2026 10:00:00 +0000\n" + + "MIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n" + body + "\n" +} + +// attachmentData is an attachment's content, and attachedMail a message carrying it. +const attachmentData = "the invoice, in full" + +func attachedMail(subject string) string { + return "From: Bob \nTo: operator@example.org\nSubject: " + subject + "\nMessage-Id: \n" + + "Date: Wed, 14 Oct 2026 11:00:00 +0000\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=XYZ\n\n" + + "--XYZ\nContent-Type: text/plain; charset=utf-8\n\nPlease find the invoice attached.\n" + + "--XYZ\nContent-Type: application/pdf; name=invoice.pdf\nContent-Disposition: attachment; filename=invoice.pdf\n" + + "Content-Transfer-Encoding: base64\n\ndGhlIGludm9pY2UsIGluIGZ1bGw=\n--XYZ--\n" +} + +// ---- the account ------------------------------------------------------------------------------------------------ + +func testAccount() Account { + return Account{Name: "mesh", Kind: KindIMAP, Address: testUser, Incoming: Server{Host: "localhost", Port: 993, Security: "tls"}} +} + +func giveSecret(t *testing.T, dir string, a Account, pw string) { + t.Helper() + if err := os.WriteFile(SecretPath(dir, a), []byte(pw+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +// eventually waits for a condition, failing with what it last saw. +func eventually(t *testing.T, within time.Duration, what string, ok func() (bool, string)) { + t.Helper() + deadline := time.Now().Add(within) + last := "" + for time.Now().Before(deadline) { + var done bool + if done, last = ok(); done { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("%s did not happen within %s; last seen: %s", what, within, last) +} + +// ---- postgres --------------------------------------------------------------------------------------------------- + +var ( + pgOnce sync.Once + pgBase string + pgDir string + pgWhy string + pgCount int + pgMu sync.Mutex +) + +func startPostgres() { + if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" { + pgBase = u + return + } + initdb, err := exec.LookPath("initdb") + if err != nil { + pgWhy = "no postgres: MESH_TEST_POSTGRES is unset and the machine has no initdb" + return + } + pgctl := filepath.Join(filepath.Dir(initdb), "pg_ctl") + dir, err := os.MkdirTemp("", "mmpg") + if err != nil { + pgWhy = err.Error() + return + } + pgDir = dir + if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil { + pgWhy = fmt.Sprintf("initdb: %v: %s", err, out) + return + } + if out, err := exec.Command(pgctl, "-D", dir+"/data", "-l", dir+"/log", "-w", "-o", + "-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil { + pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out) + return + } + pgBase = "postgres://postgres@/postgres?host=" + dir +} + +func stopPostgres() { + if pgDir == "" { + return + } + if initdb, err := exec.LookPath("initdb"); err == nil { + _ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run() + } + _ = os.RemoveAll(pgDir) +} + +// freshDatabase is a database of its own for one test, or a skip saying why there is none. +func freshDatabase(t *testing.T) string { + t.Helper() + pgOnce.Do(startPostgres) + if pgBase == "" { + t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy) + } + pgMu.Lock() + pgCount++ + name := fmt.Sprintf("mail_%d_%d", os.Getpid(), pgCount) + pgMu.Unlock() + ctx := context.Background() + admin, err := OpenPgStore(ctx, pgBase) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + if _, err := admin.pool.Exec(ctx, "create database "+name); err != nil { + t.Fatal(err) + } + u, err := url.Parse(pgBase) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + return u.String() +} + +func pgStore(t *testing.T) *PgStore { + t.Helper() + s, err := OpenPgStore(context.Background(), freshDatabase(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + if _, err := s.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + return s +} + +// eachStore runs a test over the index in memory and on postgres. +func eachStore(t *testing.T, run func(t *testing.T, s Store)) { + t.Run("memory", func(t *testing.T) { run(t, newMemStore()) }) + t.Run("postgres", func(t *testing.T) { run(t, pgStore(t)) }) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/mailevent.go b/modules/mesh-mail/cmd/mesh-mail/mailevent.go new file mode 100644 index 00000000..fb9596fb --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/mailevent.go @@ -0,0 +1,13 @@ +package main + +// The shape of a mail-store message event (novox/hq ADR 0378 §2, issue 800 part A), moved here on its own so +// mesh-mail-notifier (issue 800 part B) carries a byte-identical copy, the way modules/networkmanager's +// uplink-tools carries ifwatchd's own file (novox/hq ADR 0241, copies_test.go). Nothing here reaches a mailbox or +// a server; it only says where an account's change is told on the bus and what the three changes are called. + +// EventKey is where an account's change is said: mail..message. (research 049 +// document 02 §5), the account by its name in the setting, never its address. +func EventKey(account, change string) string { return "mail." + account + ".message." + change } + +// Changes are the three things said of a message. +var Changes = []string{"added", "changed", "removed"} diff --git a/modules/mesh-mail/cmd/mesh-mail/main.go b/modules/mesh-mail/cmd/mesh-mail/main.go new file mode 100644 index 00000000..02d4fffa --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/main.go @@ -0,0 +1,291 @@ +// mesh-mail: the operator's mail accounts, watched, and each mail said on the bus (novox/hq ADR 0359). One instance, +// on the control-node. Reading is read-only: no flag, move, delete or draft. mail_send is the one way out (novox/hq +// ADR 0361 §2): it sends nothing itself, and reaches an account's outgoing server only on the operator's Approve. +// +// mesh-mail the bundle the node's runtime launches: the tools mail_accounts, mail_search, mail_read, +// mail_health, mail_send and mail_send_status, and the events the keeper wrote, said on the +// bus as the module +// mesh-mail prepare the preparation step: the index's schema brought to this version's +// mesh-mail keep acts: watches every account (IDLE on the inbox, a look at the other folders every five +// minutes), keeps the index and the attachment store, and writes each change's event beside it +// mesh-mail watch judges: reads every minute how each account is, and leaves its verdict for mail_health +// +// The two long-running processes are the act and the judge, as mesh-knowledge's are: keep is judged by its unit, watch +// by the tool mail_health (ADR 0359 §6, ADR 0240 §3 and §7). Only the bundle reaches the bus, through the runtime that +// launched it, so the keeper hands its events over in the index, written in the same step as the change they say, +// and the bundle says them and removes each once the bus took it. stdout is the bundle's MCP channel; what this module +// says, it says on stderr. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Module is this module's name. +const Module = "mesh-mail" + +// Env is where the mesh puts what the module reads, as the manifest's environment names it. +type Env struct { + Accounts string // the trusted setting accounts + Grants string // the trusted setting grants + Secrets string // the directory the own secrets mail-credential- are placed in + Store string // the attachment store's facts + Status string // the watch's verdict +} + +func envFromOS() Env { + return Env{ + Accounts: os.Getenv("MESH_MAIL_ACCOUNTS"), + Grants: os.Getenv("MESH_MAIL_GRANTS"), + Secrets: os.Getenv("MESH_MAIL_SECRETS"), + Store: os.Getenv("MESH_MAIL_STORE"), + Status: os.Getenv("MESH_MAIL_STATUS"), + } +} + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "prepare": + os.Exit(prepare()) + case "keep": + os.Exit(keep(envFromOS())) + case "watch": + os.Exit(watch(envFromOS())) + } + } + serve(envFromOS()) +} + +// ---- prepare ------------------------------------------------------------------------------------------------------ + +func prepare() int { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + s, err := PgStoreFromEnv(ctx) + if err != nil { + logf("[mesh-mail] preparing the index: %v", err) + return 1 + } + defer s.Close() + said, err := s.Prepare(ctx) + for _, line := range said { + logf("[mesh-mail] %s", line) + } + if err != nil { + logf("[mesh-mail] preparing the index failed: %v", err) + return 1 + } + return 0 +} + +// ---- keep --------------------------------------------------------------------------------------------------------- + +// keep watches every account until it is stopped. A setting it cannot read, an index that is behind or a store it +// cannot reach ends it with a fault, which its unit says; the mesh starts it again. +func keep(env Env) int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + accounts, err := ReadAccounts(env.Accounts) + if err != nil { + logf("[mesh-mail] %v", err) + return 1 + } + st, err := PgStoreFromEnv(ctx) + if err != nil { + logf("[mesh-mail] the index cannot be opened: %v", err) + return 1 + } + defer st.Close() + if err := st.Ready(ctx); err != nil { + logf("[mesh-mail] %v", err) + return 1 + } + facts, err := ReadStoreFacts(env.Store) + if err != nil { + logf("[mesh-mail] %v", err) + return 1 + } + k := &Keeper{Store: st, Objects: NewS3(facts), Dial: DialServer, SecretsDir: env.Secrets, Logf: logf} + logf("[mesh-mail] keeping %d account(s)", len(accounts)) + k.Run(ctx, accounts) + return 0 +} + +// ---- watch -------------------------------------------------------------------------------------------------------- + +func watch(env Env) int { + if env.Status == "" { + logf("[mesh-mail] watch was started without MESH_MAIL_STATUS") + return 2 + } + open := func(ctx context.Context) (Store, func(), error) { + s, err := PgStoreFromEnv(ctx) + if err != nil { + return nil, nil, err + } + return s, s.Close, nil + } + for { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + now := time.Now().UTC() + answer := watchOnce(ctx, env, open, now) + cancel() + if err := writeStatus(env.Status, Status{Answer: answer, At: now}); err != nil { + logf("[mesh-mail] the verdict could not be written: %v", err) + } + time.Sleep(WatchEvery) + } +} + +// ---- the bundle --------------------------------------------------------------------------------------------------- + +func serve(env Env) { + var mu sync.Mutex + var st *PgStore + b := &Bundle{ + Env: env, + Index: func(ctx context.Context) (Store, error) { + mu.Lock() + defer mu.Unlock() + if st == nil { + s, err := PgStoreFromEnv(ctx) + if err != nil { + return nil, err + } + st = s + } + if err := st.Ready(ctx); err != nil { + return nil, err + } + return st, nil + }, + Objects: func() (Objects, error) { + facts, err := ReadStoreFacts(env.Store) + if err != nil { + return nil, err + } + return NewS3(facts), nil + }, + } + b.Logf = logf + p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf} + go p.Run(context.Background()) + go hear(b) + go sweepSends(b) + if err := stdio.Serve("", b.Tools()); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// hear subscribes to the router's word on this module's own asks, asked again until the runtime takes it, exactly +// as mesh-knowledge does. Each warrant heard goes to Heard, the same Decide that mail_send_status uses: an +// approved send goes out as soon as the operator answers it, never only when something later calls +// mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790). +func hear(b *Bundle) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + time.Sleep(wait) + err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w asks.Warrant + if json.Unmarshal(e.Body, &w) == nil { + b.Heard(w) + } + return nil + }) + if err == nil { + logf("[mesh-mail] hears the operator's answers") + return + } + logf("[mesh-mail] does not hear the operator's answers yet (%v); asking again in %s", err, wait) + } +} + +// sweepSends reads every send still undecided once a minute and asks the router's own record for its answer, so +// an Approve answered while this module could not hear it directly is still acted on (novox/hq issue 790, review +// round 1). +func sweepSends(b *Bundle) { + tick := time.NewTicker(SweepEvery) + defer tick.Stop() + for { + b.Sweep(time.Now()) + <-tick.C + } +} + +// Publisher says the events the keeper wrote, oldest first, and removes each once the bus took it. One the bus did not +// take stays, and is said again on the next round: delivery is at least once, and a consumer reads the uid and the +// folder to know a repeat. +type Publisher struct { + Index func(ctx context.Context) (Store, error) + Emit func(key string, body any) error + Logf func(format string, a ...any) + Every time.Duration +} + +// PublishBatch is how many events one round says at most. +const PublishBatch = 100 + +// Once says what is pending, and answers how many it said. +func (p *Publisher) Once(ctx context.Context) (int, error) { + st, err := p.Index(ctx) + if err != nil { + return 0, err + } + pending, err := st.Pending(ctx, PublishBatch) + if err != nil { + return 0, err + } + var said []int64 + var failed error + for _, e := range pending { + if err := p.Emit(e.Key, e.Body); err != nil { + failed = fmt.Errorf("the bus did not take %s: %v", e.Key, err) + break + } + said = append(said, e.ID) + } + if len(said) > 0 { + if err := st.Said(ctx, said); err != nil { + return 0, err + } + } + return len(said), failed +} + +// Run says pending events until ctx ends: every few seconds, at once again while a round was full. +func (p *Publisher) Run(ctx context.Context) { + every := p.Every + if every <= 0 { + every = 3 * time.Second + } + // The runtime takes the bundle's first calls only once Serve runs. + time.Sleep(every) + for ctx.Err() == nil { + c, cancel := context.WithTimeout(ctx, time.Minute) + n, err := p.Once(c) + cancel() + if err != nil && p.Logf != nil { + p.Logf("[mesh-mail] events wait to be said: %v", err) + } + if n == PublishBatch { + continue + } + select { + case <-ctx.Done(): + case <-time.After(every): + } + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go new file mode 100644 index 00000000..87b01d30 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The manifest says what the code does (novox/hq ADR 0359): the database and the bucket it requires (§3), the own +// secret family issued outside the mesh (§5, ADR 0283 §6), the two trusted settings (§4), the tools (§5), the events, +// its two long-running processes with their health (§6), and — ADR 0378 as amended by ADR 0379, issue 800 part A — +// the three mail seats it declares and the one, mail-store, it claims. It is assigned to no machine by itself. + +type seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + Needs []string `json:"needs"` +} + +type manifest struct { + Module string `json:"module"` + Slug string `json:"slug"` + Seats []seat `json:"seats"` + Requires []string `json:"requires"` + Contributes map[string]map[string]any `json:"contributes"` + OwnSecrets map[string]map[string]string `json:"own-secrets"` + Tools []string `json:"tools"` + Emits []string `json:"emits"` + Streams []struct { + Name string `json:"name"` + Events []string `json:"events"` + MaxAgeDays int `json:"max-age-days"` + Grant struct { + Config string `json:"config"` + Events string `json:"events"` + } `json:"grant"` + } `json:"streams"` + Consumes []string `json:"consumes"` + Invokes []string `json:"invokes"` + Claims []seat `json:"claims"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []struct { + From string `json:"from"` + Binary string `json:"binary"` + Loads []string `json:"loads"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) (manifest, map[string]map[string]any) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + byID := map[string]map[string]any{} + for _, r := range m.Resources { + byID[r["id"].(string)] = r + } + return m, byID +} + +func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { + m, byID := readManifest(t) + if m.Module != Module || !reflect.DeepEqual(m.Requires, []string{"postgres-database", "s3-bucket"}) || m.Contributes["postgres-database"]["name"] != "mail" { + t.Fatalf("module %s, requires %v, contributes %v", m.Module, m.Requires, m.Contributes) + } + // mesh__ fits the S3 access key's 20 characters on every node. + if len("mesh_shanks_"+m.Slug) > 20 { + t.Fatalf("the slug %q overflows an S3 access key", m.Slug) + } + fam := m.OwnSecrets[SecretFamily+"*"] + if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 1 { + t.Fatalf("own secrets %v", m.OwnSecrets) + } + var names []string + for _, tool := range (&Bundle{}).Tools() { + names = append(names, tool.Name) + } + wantTools := []string{ + "mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status", + "mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", + } + if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) { + t.Fatalf("tools %v, the code serves %v", m.Tools, names) + } + // Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted + // config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream. + var events []string + for _, c := range Changes { + events = append(events, EventKey("*", c)) + } + if len(m.Emits) != 0 || len(m.Streams) != 1 || m.Streams[0].Name != "mail" || !reflect.DeepEqual(m.Streams[0].Events, events) || + m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" { + t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events) + } + // It calls nothing and consumes nothing: mesh-mail-notifier (issue 800 part B) is the one that invokes and + // consumes, not the store. + if len(m.Consumes)+len(m.Invokes) != 0 { + t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) + } + // It declares exactly the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and + // mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier + // there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3). + wantSeats := []seat{ + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, + {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}}, + } + if !reflect.DeepEqual(m.Seats, wantSeats) { + t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) + } + wantClaims := []seat{ + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + } + if !reflect.DeepEqual(m.Claims, wantClaims) { + t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) + } + // The two settings are trusted, and their defaults are an empty account list and no grant. + for id, want := range map[string]string{"accounts": `{"accounts":{}}`, "grants": `{"grants":{}}`} { + r := byID[id] + if r == nil || r["trusted"] != true || r["merge"] != "json" || !reflect.DeepEqual(r["takes-config"], []any{id}) { + t.Fatalf("%s: %v", id, r) + } + // The file's text is kept once, in its template under files/ (novox/hq ADR 0349 §2). + raw, err := os.ReadFile(filepath.Join("../..", r["template"].(string))) + if err != nil { + t.Fatal(err) + } + if strings.Join(strings.Fields(string(raw)), "") != want { + t.Fatalf("%s's default is %q", id, raw) + } + if id == "accounts" { + if _, err := ParseAccounts(raw); err != nil { + t.Fatal(err) + } + } + } + a := m.Build.Artifacts + if len(a) != 2 || a[1].From != "files" || a[0].From != "cmd/mesh-mail" || a[0].Binary != Module || !reflect.DeepEqual(a[0].Loads, []string{Module}) { + t.Fatalf("build %+v", m.Build) + } + for k, v := range map[string]string{"MESH_MAIL_ACCOUNTS": "accounts", "MESH_MAIL_GRANTS": "grants", "MESH_MAIL_STORE": "store", "DATABASE_URL_FILE": "database-url"} { + if a[0].Env[k] != byID[v]["path"] { + t.Errorf("the bundle reads %s at %s, and the manifest writes %s at %v", k, a[0].Env[k], v, byID[v]["path"]) + } + } + if a[0].Env["MESH_MAIL_SECRETS"] != "${dir:state}" { + t.Errorf("the secrets directory is %s", a[0].Env["MESH_MAIL_SECRETS"]) + } +} + +// Its processes: the preparation once, keep judged by its unit, watch by mail_health (ADR 0359 §6), the watch writing +// the verdict the bundle's tool reads, as the account the bundle runs as. +func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) { + m, byID := readManifest(t) + prep, keep, watch := byID["prepare"], byID["keep"], byID["watch"] + if prep == nil || prep["run-once"] != true || prep["run"].([]any)[1] != "prepare" { + t.Fatalf("prepare %v", prep) + } + if keep == nil || keep["run"].([]any)[1] != "keep" || keep["health"].(map[string]any)["kind"] != "unit" { + t.Fatalf("keep %v", keep) + } + h, _ := watch["health"].(map[string]any) + if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool || h["needs"] != "postgres-database" { + t.Fatalf("watch %v", watch) + } + if watch["user"] != "${machine:account}" || watch["env"].(map[string]any)["MESH_MAIL_STATUS"] != m.Build.Artifacts[0].Env["MESH_MAIL_STATUS"] { + t.Fatalf("watch writes another status than the tool reads, or as another account: %v", watch) + } + // keep runs as the machine's root, which the own secrets' files belong to; it starts again when its settings or + // its store change. + if _, as := keep["user"]; as { + t.Fatalf("keep runs as %v", keep["user"]) + } + if !reflect.DeepEqual(keep["restart-on"], []any{"database-url", "store", "accounts"}) { + t.Fatalf("keep restarts on %v", keep["restart-on"]) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/migrations/0001-the-index-holds-messages-folders-account-states-and-events-to-say.sql b/modules/mesh-mail/cmd/mesh-mail/migrations/0001-the-index-holds-messages-folders-account-states-and-events-to-say.sql new file mode 100644 index 00000000..b0060ce1 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/migrations/0001-the-index-holds-messages-folders-account-states-and-events-to-say.sql @@ -0,0 +1,46 @@ +-- mesh-mail's index (novox/hq ADR 0359 §3): one row per message and folder, what is known of each folder, each +-- account's state as its keeper said it, and the events written with the change they say, until they are said. +create table folder ( + account text not null, + name text not null, + uidvalidity bigint not null, + messages integer not null default 0, + synced timestamptz not null default now(), + primary key (account, name)); + +create table message ( + account text not null, + folder text not null, + uid bigint not null, + uidvalidity bigint not null, + message_id text not null default '', + in_reply_to text not null default '', + date timestamptz not null, + sender jsonb not null default '[]', + recipients jsonb not null default '[]', + copied jsonb not null default '[]', + people text not null default '', + subject text not null default '', + size bigint not null default 0, + flags text[] not null default '{}', + attachments jsonb not null default '[]', + body text not null default '', + search tsvector generated always as ( + to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored, + primary key (account, folder, uid)); +create index message_search on message using gin (search); +create index message_date on message (account, date desc); + +create table account_state ( + account text primary key, + state text not null, + why text not null default '', + heard timestamptz, + synced timestamptz, + said timestamptz not null default now()); + +create table outbox ( + id bigserial primary key, + key text not null, + body jsonb not null, + made timestamptz not null default now()); diff --git a/modules/mesh-mail/cmd/mesh-mail/mime.go b/modules/mesh-mail/cmd/mesh-mail/mime.go new file mode 100644 index 00000000..0c04725a --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/mime.go @@ -0,0 +1,125 @@ +package main + +// A message's text and its attachments, read from its MIME source. The text is kept in the index for search and served +// through mail_read; each attachment goes to the attachment store once, by its SHA-256 (novox/hq ADR 0359 §3). + +import ( + "bytes" + "errors" + "html" + "io" + "mime" + "regexp" + "strings" + + _ "github.com/emersion/go-message/charset" // the charsets mail is written in, beyond UTF-8 and ASCII + "github.com/emersion/go-message/mail" +) + +// Part is one attachment as read from a message. +type Part struct { + Name string + Type string + Data []byte +} + +// MostBodyText bounds the text kept per message: a longer body is cut, and says so. +const MostBodyText = 256 << 10 + +// Parsed is a message's text and its attachments. +type Parsed struct { + Text string + Attachments []Part +} + +// ParseMessage reads a message's text (its plain part; else its HTML part as text) and its attachments. A message the +// reader cannot follow keeps what was read before the fault, and the error says what. +func ParseMessage(raw []byte) (Parsed, error) { + var p Parsed + r, err := mail.CreateReader(bytes.NewReader(raw)) + if r == nil { + return p, err + } + var plain, htmlText string + for { + part, err := r.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return finish(p, plain, htmlText), err + } + switch h := part.Header.(type) { + case *mail.InlineHeader: + ct, params, _ := h.ContentType() + _, disp, _ := h.ContentDisposition() + body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20)) + if readErr != nil { + return finish(p, plain, htmlText), readErr + } + name := params["name"] + if name == "" { + name = disp["filename"] + } + switch { + case ct == "text/plain" && plain == "" && name == "": + plain = string(body) + case ct == "text/html" && htmlText == "" && name == "": + htmlText = HTMLText(string(body)) + case !strings.HasPrefix(ct, "text/") || name != "": + // An inline image or a named inline file is an attachment too. + p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body}) + } + case *mail.AttachmentHeader: + ct, _, _ := h.ContentType() + name, _ := h.Filename() + body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20)) + if readErr != nil { + return finish(p, plain, htmlText), readErr + } + p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body}) + } + } + return finish(p, plain, htmlText), nil +} + +func finish(p Parsed, plain, htmlText string) Parsed { + p.Text = plain + if strings.TrimSpace(p.Text) == "" { + p.Text = htmlText + } + p.Text = strings.ToValidUTF8(p.Text, "�") + if len(p.Text) > MostBodyText { + p.Text = strings.ToValidUTF8(p.Text[:MostBodyText], "") + "\n[the text goes on; it was cut at 256 KiB]" + } + return p +} + +func orOctets(ct string) string { + if ct == "" { + return "application/octet-stream" + } + if t, _, err := mime.ParseMediaType(ct); err == nil { + return t + } + return ct +} + +var ( + scriptsAndStyles = regexp.MustCompile(`(?is)<(script|style|head)\b.*?`) + breaks = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])\b[^>]*>`) + tags = regexp.MustCompile(`(?s)<[^>]*>`) + blankLines = regexp.MustCompile(`\n[ \t]*\n([ \t]*\n)+`) +) + +// HTMLText is an HTML body as plain text: no script, style or markup, the entities read, paragraphs kept as lines. +// Text only, so nothing of the sender's markup ever reaches whoever reads it. +func HTMLText(s string) string { + s = scriptsAndStyles.ReplaceAllString(s, "") + s = breaks.ReplaceAllString(s, "\n") + s = tags.ReplaceAllString(s, "") + s = html.UnescapeString(s) + s = strings.ReplaceAll(s, "\r", "") + s = blankLines.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/objects.go b/modules/mesh-mail/cmd/mesh-mail/objects.go new file mode 100644 index 00000000..35177817 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/objects.go @@ -0,0 +1,275 @@ +package main + +// The attachment store (novox/hq ADR 0359 §3): each attachment kept once, by its SHA-256, in the bucket the `minio` +// module provides this module through the `s3-bucket` provision. The mesh writes the bucket's facts and its key into +// one file (MESH_MAIL_STORE); this speaks S3 to it with the standard library alone: a HEAD, a PUT and a GET, each +// signed with AWS Signature Version 4, path-style, as an S3-compatible object store answers them. + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" +) + +// Objects is the attachment store. +type Objects interface { + // Bucket is the bucket's name, as an event names it. + Bucket() string + // Has says whether the key is kept. + Has(ctx context.Context, key string) (bool, error) + // Put keeps the bytes under the key. + Put(ctx context.Context, key, mediaType string, data []byte) error + // Get is the bytes kept under the key. + Get(ctx context.Context, key string) ([]byte, error) +} + +// Keep stores an attachment once by its SHA-256 and answers its reference: the same file mailed ten times is one +// object. +func Keep(ctx context.Context, o Objects, data []byte, mediaType, name string) (AttachmentRef, error) { + sum := sha256.Sum256(data) + key := hex.EncodeToString(sum[:]) + has, err := o.Has(ctx, key) + if err != nil { + return AttachmentRef{}, err + } + if !has { + if err := o.Put(ctx, key, mediaType, data); err != nil { + return AttachmentRef{}, err + } + } + return AttachmentRef{Bucket: o.Bucket(), Key: key, Size: int64(len(data)), Type: mediaType, Name: name}, nil +} + +// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret. +type StoreFacts struct { + Scheme string `json:"scheme"` + At string `json:"at"` + Port string `json:"port"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Key string `json:"key"` + Secret string `json:"secret"` +} + +// ReadStoreFacts reads the store's facts. +func ReadStoreFacts(path string) (StoreFacts, error) { + var f StoreFacts + if path == "" { + return f, errors.New("the module was started without its store's facts (MESH_MAIL_STORE)") + } + raw, err := os.ReadFile(path) + if err != nil { + return f, fmt.Errorf("the store's facts cannot be read: %w", err) + } + if err := json.Unmarshal(raw, &f); err != nil { + return f, fmt.Errorf("the store's facts are not the module's: %v", err) + } + if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" { + return f, errors.New("the store's facts name no address, bucket or key") + } + if f.Scheme == "" { + f.Scheme = "http" + } + if f.Region == "" { + f.Region = "us-east-1" + } + return f, nil +} + +// S3 is the attachment store over S3. +type S3 struct { + Endpoint string // scheme://host[:port] + Name string + Region string + Access string + Secret string + Client *http.Client + Now func() time.Time +} + +// NewS3 is the store the facts name. +func NewS3(f StoreFacts) *S3 { + host := f.At + if f.Port != "" { + host += ":" + f.Port + } + return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret, + Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now} +} + +func (s *S3) Bucket() string { return s.Name } + +func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) { + u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + if mediaType != "" { + req.Header.Set("Content-Type", mediaType) + } + req.ContentLength = int64(len(body)) + Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC()) + return s.Client.Do(req) +} + +func (s *S3) Has(ctx context.Context, key string) (bool, error) { + resp, err := s.do(ctx, http.MethodHead, key, "", nil) + if err != nil { + return false, fmt.Errorf("the attachment store cannot be reached: %w", err) + } + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + } + return false, fmt.Errorf("the attachment store answered %s to asking for %s", resp.Status, key) +} + +func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error { + resp, err := s.do(ctx, http.MethodPut, key, mediaType, data) + if err != nil { + return fmt.Errorf("the attachment store cannot be reached: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + said, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("the attachment store refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said))) + } + return nil +} + +func (s *S3) Get(ctx context.Context, key string) ([]byte, error) { + resp, err := s.do(ctx, http.MethodGet, key, "", nil) + if err != nil { + return nil, fmt.Errorf("the attachment store cannot be reached: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the attachment store answered %s for %s", resp.Status, key) + } + return io.ReadAll(resp.Body) +} + +// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is +// signed, with the payload's hash. +func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) { + payload := sha256.Sum256(body) + payloadHex := hex.EncodeToString(payload[:]) + amzDate := now.Format("20060102T150405Z") + day := now.Format("20060102") + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHex) + + headers := map[string]string{"host": req.URL.Host} + for k, v := range req.Header { + lk := strings.ToLower(k) + if lk == "user-agent" || lk == "content-length" || lk == "authorization" { + continue + } + headers[lk] = strings.TrimSpace(strings.Join(v, ",")) + } + names := make([]string, 0, len(headers)) + for k := range headers { + names = append(names, k) + } + sort.Strings(names) + var canonHeaders strings.Builder + for _, k := range names { + canonHeaders.WriteString(k + ":" + headers[k] + "\n") + } + signed := strings.Join(names, ";") + path := req.URL.EscapedPath() + if path == "" { + path = "/" + } + canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n") + scope := day + "/" + region + "/s3/aws4_request" + sum := sha256.Sum256([]byte(canonical)) + toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:]) + key := hmacOf([]byte("AWS4"+secret), day) + key = hmacOf(key, region) + key = hmacOf(key, "s3") + key = hmacOf(key, "aws4_request") + sig := hex.EncodeToString(hmacOf(key, toSign)) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig) +} + +func canonicalQuery(q url.Values) string { + keys := make([]string, 0, len(q)) + for k := range q { + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + vs := append([]string(nil), q[k]...) + sort.Strings(vs) + for _, v := range vs { + parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20")) + } + } + return strings.Join(parts, "&") +} + +func hmacOf(key []byte, data string) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return h.Sum(nil) +} + +// ---- in memory ---------------------------------------------------------------------------------------------------- + +type memObjects struct { + mu sync.Mutex + name string + kept map[string][]byte + puts int +} + +func newMemObjects() *memObjects { return &memObjects{name: "mail-test", kept: map[string][]byte{}} } + +func (m *memObjects) Bucket() string { return m.name } + +func (m *memObjects) Has(_ context.Context, key string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.kept[key] + return ok, nil +} + +func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.kept[key] = append([]byte(nil), data...) + m.puts++ + return nil +} + +func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.kept[key] + if !ok { + return nil, fmt.Errorf("%s is not kept", key) + } + return b, nil +} diff --git a/modules/mesh-mail/cmd/mesh-mail/objects_test.go b/modules/mesh-mail/cmd/mesh-mail/objects_test.go new file mode 100644 index 00000000..1c24de34 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/objects_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// The signature is AWS Signature Version 4 as S3 documents it: its own worked example, "GET Object", signs to the +// signature AWS publishes. +func TestTheSignatureIsTheOneS3Documents(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/test.txt", nil) + req.Header.Set("Range", "bytes=0-9") + Sign(req, nil, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1", time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC)) + want := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + + "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" + if got := req.Header.Get("Authorization"); got != want { + t.Fatalf("signed\n%s\nnot\n%s", got, want) + } +} + +// An attachment is kept once by its SHA-256: the same file twice is one PUT, and it reads back. +func TestAnAttachmentIsKeptOnceByItsHash(t *testing.T) { + var mu sync.Mutex + kept := map[string][]byte{} + puts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=mesh_novox_mail/") { + w.WriteHeader(http.StatusForbidden) + return + } + mu.Lock() + defer mu.Unlock() + switch r.Method { + case http.MethodHead: + if _, ok := kept[r.URL.Path]; !ok { + w.WriteHeader(http.StatusNotFound) + } + case http.MethodPut: + body, _ := io.ReadAll(r.Body) + kept[r.URL.Path] = body + puts++ + case http.MethodGet: + b, ok := kept[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write(b) + } + })) + defer srv.Close() + dir := t.TempDir() + path := filepath.Join(dir, "store.json") + host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":") + writeFile(t, path, `{"scheme": "http", "at": "`+host+`", "port": "`+port+`", "bucket": "mesh-novox-mail", "region": "eu-west", + "key": "mesh_novox_mail", "secret": "s3cr3t"}`) + facts, err := ReadStoreFacts(path) + if err != nil { + t.Fatal(err) + } + s3 := NewS3(facts) + ctx := context.Background() + a, err := Keep(ctx, s3, []byte("same file"), "text/plain", "a.txt") + if err != nil { + t.Fatal(err) + } + b, err := Keep(ctx, s3, []byte("same file"), "text/plain", "b.txt") + if err != nil { + t.Fatal(err) + } + if a.Key != b.Key || a.Bucket != "mesh-novox-mail" || puts != 1 || len(a.Key) != 64 { + t.Fatalf("%+v %+v, %d put(s)", a, b, puts) + } + if _, ok := kept["/mesh-novox-mail/"+a.Key]; !ok { + t.Fatalf("kept %v", kept) + } + got, err := s3.Get(ctx, a.Key) + if err != nil || string(got) != "same file" { + t.Fatalf("%q %v", got, err) + } +} + +func TestAMessagesTextAndAttachmentsAreRead(t *testing.T) { + p, err := ParseMessage([]byte(strings.ReplaceAll(attachedMail("x"), "\n", "\r\n"))) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(p.Text) != "Please find the invoice attached." || len(p.Attachments) != 1 || + p.Attachments[0].Name != "invoice.pdf" || p.Attachments[0].Type != "application/pdf" || string(p.Attachments[0].Data) != attachmentData { + t.Fatalf("%+v", p) + } + html := "From: a@b.c\r\nSubject: s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n

Hello & welcome

Bye

" + p, err = ParseMessage([]byte(html)) + if err != nil || p.Text != "Hello & welcome\nBye" { + t.Fatalf("%q %v", p.Text, err) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/pgstore.go b/modules/mesh-mail/cmd/mesh-mail/pgstore.go new file mode 100644 index 00000000..0c63fec9 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/pgstore.go @@ -0,0 +1,504 @@ +package main + +// The index on the mesh's postgres (novox/hq ADR 0359 §3): the database the mesh provisions for this module through +// the postgres-database provision, its address in the file DATABASE_URL_FILE names. Its schema is numbered migrations +// in migrations/, applied by the preparation step (`mesh-mail prepare`, ADR 0135) and recorded in the database itself; +// a fresh database is given the consolidated schema, schema.sql, at once, with every migration recorded as applied. +// Nothing else changes the schema: the keeper, the watch and the tools refuse a schema that is behind, and say so. + +import ( + "context" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +//go:embed schema.sql +var consolidated string + +// Migration is one numbered change of the schema. +type Migration struct { + Number int + Name string + SQL string + Checksum string +} + +var migrationFile = regexp.MustCompile(`^(\d{4})-([a-z0-9-]+)\.sql$`) + +// Migrations are the migrations this binary carries, in order, numbered from 0001 without a gap. +func Migrations() ([]Migration, error) { + entries, err := fs.ReadDir(migrationFiles, "migrations") + if err != nil { + return nil, err + } + var out []Migration + for _, e := range entries { + m := migrationFile.FindStringSubmatch(e.Name()) + if m == nil { + return nil, fmt.Errorf("%s is not a migration's name (NNNN-what-it-does.sql)", e.Name()) + } + n, _ := strconv.Atoi(m[1]) + body, err := fs.ReadFile(migrationFiles, path.Join("migrations", e.Name())) + if err != nil { + return nil, err + } + if strings.TrimSpace(string(body)) == "" { + return nil, fmt.Errorf("%s is empty", e.Name()) + } + sum := sha256.Sum256(body) + out = append(out, Migration{Number: n, Name: m[2], SQL: string(body), Checksum: hex.EncodeToString(sum[:])}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number }) + for i, m := range out { + if m.Number != i+1 { + return nil, fmt.Errorf("the migrations are not numbered 0001 on without a gap: %04d is the %d. one", m.Number, i+1) + } + } + return out, nil +} + +const ledger = `create table if not exists migration ( + number integer primary key, + name text not null, + checksum text not null, + applied timestamptz not null default now())` + +// lockKey is the advisory lock every migration run takes, so two preparations never interleave. +const lockKey int64 = 4_250_359_001 + +// PgStore is the Store on postgres. +type PgStore struct{ pool *pgxpool.Pool } + +// PgStoreFromEnv opens the index the mesh provisioned. +func PgStoreFromEnv(ctx context.Context) (*PgStore, error) { + file := os.Getenv("DATABASE_URL_FILE") + if file == "" { + return nil, errors.New("DATABASE_URL_FILE is not set: the index's database is a requirement the mesh resolves") + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("the database's address cannot be read: %w", err) + } + return OpenPgStore(ctx, strings.TrimSpace(string(raw))) +} + +// OpenPgStore opens the index at an address. +func OpenPgStore(ctx context.Context, url string) (*PgStore, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, err + } + return &PgStore{pool: pool}, nil +} + +// Close closes the store. +func (s *PgStore) Close() { s.pool.Close() } + +// Prepare brings the schema to what this version needs, and says what it did. +func (s *PgStore) Prepare(ctx context.Context) ([]string, error) { + migrations, err := Migrations() + if err != nil { + return nil, err + } + conn, err := s.pool.Acquire(ctx) + if err != nil { + return nil, err + } + defer conn.Release() + if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, lockKey); err != nil { + return nil, fmt.Errorf("the migration lock cannot be taken: %w", err) + } + defer func() { _, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock($1)`, lockKey) }() + if _, err := conn.Exec(ctx, ledger); err != nil { + return nil, err + } + applied := map[int]string{} + rows, err := conn.Query(ctx, `select number, checksum from migration`) + if err != nil { + return nil, err + } + highest := 0 + for rows.Next() { + var n int + var sum string + if err := rows.Scan(&n, &sum); err != nil { + rows.Close() + return nil, err + } + applied[n] = sum + highest = max(highest, n) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + record := func(tx pgx.Tx, m Migration) error { + _, err := tx.Exec(ctx, `insert into migration (number, name, checksum) values ($1, $2, $3)`, m.Number, m.Name, m.Checksum) + return err + } + if len(applied) == 0 { + var exists bool + if err := conn.QueryRow(ctx, `select to_regclass('message') is not null`).Scan(&exists); err != nil { + return nil, err + } + if exists { + return nil, errors.New("the database holds the index's tables and no record of a migration: its contents are not known, so nothing is changed") + } + tx, err := conn.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + if _, err := tx.Exec(ctx, consolidated); err != nil { + return nil, fmt.Errorf("the consolidated schema failed, and nothing of it was kept: %w", err) + } + for _, m := range migrations { + if err := record(tx, m); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return []string{fmt.Sprintf("a fresh database: the consolidated schema, through migration %04d", len(migrations))}, nil + } + var problems []string + var pending []Migration + for _, m := range migrations { + sum, ran := applied[m.Number] + switch { + case ran && sum != m.Checksum: + problems = append(problems, fmt.Sprintf("migration %04d-%s ran, and its file has changed since: write a new one", m.Number, m.Name)) + case !ran && m.Number < highest: + problems = append(problems, fmt.Sprintf("migration %04d-%s never ran, and %04d did: renumber it above %04d", m.Number, m.Name, highest, highest)) + case !ran: + pending = append(pending, m) + } + } + if len(problems) > 0 { + return nil, errors.New("this database and these migrations disagree: " + strings.Join(problems, "; ")) + } + var said []string + for _, m := range pending { + err := func() error { + tx, err := conn.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + if _, err := tx.Exec(ctx, m.SQL); err != nil { + return fmt.Errorf("migration %04d-%s failed, and nothing it did was kept: %w", m.Number, m.Name, err) + } + if err := record(tx, m); err != nil { + return err + } + return tx.Commit(ctx) + }() + if err != nil { + return said, err + } + said = append(said, fmt.Sprintf("migration %04d-%s applied", m.Number, m.Name)) + } + if len(said) == 0 { + said = []string{fmt.Sprintf("the schema is at migration %04d, as this version needs", len(migrations))} + } + return said, nil +} + +// Ready says why the index cannot serve this version, or nil. +func (s *PgStore) Ready(ctx context.Context) error { + migrations, err := Migrations() + if err != nil { + return err + } + var at int + err = s.pool.QueryRow(ctx, `select coalesce(max(number), 0) from migration`).Scan(&at) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "42P01" { + at = 0 + } else if err != nil { + return fmt.Errorf("the database cannot be reached: %w", err) + } + if at != len(migrations) { + return fmt.Errorf("the database's schema is at migration %04d and this version needs %04d: the preparation step "+ + "(mesh-mail prepare) has not run", at, len(migrations)) + } + return nil +} + +func (s *PgStore) Folder(ctx context.Context, account, folder string) (Folder, bool, error) { + f := Folder{Account: account, Name: folder} + var validity int64 + err := s.pool.QueryRow(ctx, `select uidvalidity, messages, synced from folder where account = $1 and name = $2`, account, folder). + Scan(&validity, &f.Messages, &f.Synced) + if errors.Is(err, pgx.ErrNoRows) { + return f, false, nil + } + f.UIDValidity = uint32(validity) + return f, err == nil, err +} + +func (s *PgStore) SetFolder(ctx context.Context, f Folder) error { + _, err := s.pool.Exec(ctx, `insert into folder (account, name, uidvalidity, messages, synced) values ($1, $2, $3, $4, $5) + on conflict (account, name) do update set uidvalidity = excluded.uidvalidity, messages = excluded.messages, synced = excluded.synced`, + f.Account, f.Name, int64(f.UIDValidity), f.Messages, f.Synced) + return err +} + +func (s *PgStore) DropFolder(ctx context.Context, account, folder string) error { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2`, account, folder); err != nil { + return err + } + _, err := tx.Exec(ctx, `delete from folder where account = $1 and name = $2`, account, folder) + return err + }) +} + +func (s *PgStore) Flags(ctx context.Context, account, folder string) (map[uint32][]string, error) { + rows, err := s.pool.Query(ctx, `select uid, flags from message where account = $1 and folder = $2`, account, folder) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[uint32][]string{} + for rows.Next() { + var uid int64 + var flags []string + if err := rows.Scan(&uid, &flags); err != nil { + return nil, err + } + out[uint32(uid)] = flags + } + return out, rows.Err() +} + +func keepEvent(ctx context.Context, tx pgx.Tx, e *Event) error { + if e == nil { + return nil + } + body, err := json.Marshal(e.Body) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `insert into outbox (key, body) values ($1, $2)`, e.Key, body) + return err +} + +func (s *PgStore) Add(ctx context.Context, m Message, e *Event) error { + from, _ := json.Marshal(m.From) + to, _ := json.Marshal(m.To) + cc, _ := json.Marshal(orNone(m.Cc)) + atts, _ := json.Marshal(m.Attachments) + flags := m.Flags + if flags == nil { + flags = []string{} + } + people := strings.Join([]string{peopleText(m.From), peopleText(m.To), peopleText(m.Cc)}, " ") + // An address is one word to the search, so its parts are kept beside it: "bob" finds bob@example.org. + people += " " + strings.NewReplacer("@", " ", ".", " ", ",", " ").Replace(people) + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `insert into message (account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender, + recipients, copied, people, subject, size, flags, attachments, body) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + on conflict (account, folder, uid) do update set uidvalidity = excluded.uidvalidity, message_id = excluded.message_id, + in_reply_to = excluded.in_reply_to, date = excluded.date, sender = excluded.sender, recipients = excluded.recipients, + copied = excluded.copied, people = excluded.people, subject = excluded.subject, size = excluded.size, + flags = excluded.flags, attachments = excluded.attachments, body = excluded.body`, + m.Account, m.Folder, int64(m.UID), int64(m.UIDValidity), m.MessageID, m.InReplyTo, m.Date, from, to, cc, people, + strings.ToValidUTF8(strings.ReplaceAll(m.Subject, "\x00", ""), ""), m.Size, flags, atts, + strings.ReplaceAll(m.Body, "\x00", "")); err != nil { + return err + } + return keepEvent(ctx, tx, e) + }) +} + +func orNone(ps []Person) []Person { + if ps == nil { + return []Person{} + } + return ps +} + +func (s *PgStore) SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `update message set flags = $4 where account = $1 and folder = $2 and uid = $3`, + account, folder, int64(uid), flags); err != nil { + return err + } + return keepEvent(ctx, tx, e) + }) +} + +func (s *PgStore) Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2 and uid = $3`, account, folder, int64(uid)); err != nil { + return err + } + return keepEvent(ctx, tx, e) + }) +} + +const messageColumns = `account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender, recipients, copied, subject, + size, flags, attachments` + +func scanMessage(row pgx.Row, withBody bool) (Message, error) { + var m Message + var uid, validity int64 + var from, to, cc, atts []byte + dest := []any{&m.Account, &m.Folder, &uid, &validity, &m.MessageID, &m.InReplyTo, &m.Date, &from, &to, &cc, &m.Subject, + &m.Size, &m.Flags, &atts} + if withBody { + dest = append(dest, &m.Body) + } + if err := row.Scan(dest...); err != nil { + return m, err + } + m.UID, m.UIDValidity, m.Date = uint32(uid), uint32(validity), m.Date.UTC() + for _, f := range []struct { + raw []byte + into any + }{{from, &m.From}, {to, &m.To}, {cc, &m.Cc}, {atts, &m.Attachments}} { + if err := json.Unmarshal(f.raw, f.into); err != nil { + return m, err + } + } + return m, nil +} + +func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error) { + m, err := scanMessage(s.pool.QueryRow(ctx, `select `+messageColumns+`, body from message where account = $1 and folder = $2 and uid = $3`, + account, folder, int64(uid)), true) + if errors.Is(err, pgx.ErrNoRows) { + return Message{}, false, nil + } + return m, err == nil, err +} + +func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) { + limit := q.Limit + if limit <= 0 { + limit = 20 + } + rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message + where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3) + order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + m, err := scanMessage(rows, false) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *PgStore) Folders(ctx context.Context, account string) ([]Folder, error) { + rows, err := s.pool.Query(ctx, `select name, uidvalidity, messages, synced from folder where account = $1 order by name`, account) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Folder + for rows.Next() { + f := Folder{Account: account} + var validity int64 + if err := rows.Scan(&f.Name, &validity, &f.Messages, &f.Synced); err != nil { + return nil, err + } + f.UIDValidity, f.Synced = uint32(validity), f.Synced.UTC() + out = append(out, f) + } + return out, rows.Err() +} + +func (s *PgStore) Pending(ctx context.Context, limit int) ([]Event, error) { + rows, err := s.pool.Query(ctx, `select id, key, body from outbox order by id limit $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Event + for rows.Next() { + var e Event + var body []byte + if err := rows.Scan(&e.ID, &e.Key, &body); err != nil { + return nil, err + } + if err := json.Unmarshal(body, &e.Body); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *PgStore) Said(ctx context.Context, ids []int64) error { + _, err := s.pool.Exec(ctx, `delete from outbox where id = any($1)`, ids) + return err +} + +func (s *PgStore) SetState(ctx context.Context, st AccountState) error { + _, err := s.pool.Exec(ctx, `insert into account_state (account, state, why, heard, synced, said) values ($1, $2, $3, $4, $5, $6) + on conflict (account) do update set state = excluded.state, why = excluded.why, heard = excluded.heard, + synced = excluded.synced, said = excluded.said`, + st.Account, st.State, st.Why, nullTime(st.Heard), nullTime(st.Synced), st.Said) + return err +} + +func nullTime(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func (s *PgStore) States(ctx context.Context) (map[string]AccountState, error) { + rows, err := s.pool.Query(ctx, `select account, state, why, heard, synced, said from account_state`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]AccountState{} + for rows.Next() { + var st AccountState + var heard, synced *time.Time + if err := rows.Scan(&st.Account, &st.State, &st.Why, &heard, &synced, &st.Said); err != nil { + return nil, err + } + if heard != nil { + st.Heard = heard.UTC() + } + if synced != nil { + st.Synced = synced.UTC() + } + st.Said = st.Said.UTC() + out[st.Account] = st + } + return out, rows.Err() +} diff --git a/modules/mesh-mail/cmd/mesh-mail/pgstore_test.go b/modules/mesh-mail/cmd/mesh-mail/pgstore_test.go new file mode 100644 index 00000000..109945c6 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/pgstore_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "reflect" + "strings" + "testing" +) + +// The schema is numbered migrations and a consolidated schema for a fresh database (the mesh's rule: both). A +// database given the consolidated schema and one given every migration in order hold the same tables and columns. +func TestTheConsolidatedSchemaIsTheMigrationsApplied(t *testing.T) { + ctx := context.Background() + fresh := pgStore(t) + stepped, err := OpenPgStore(ctx, freshDatabase(t)) + if err != nil { + t.Fatal(err) + } + defer stepped.Close() + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + for _, m := range migrations { + if _, err := stepped.pool.Exec(ctx, m.SQL); err != nil { + t.Fatalf("migration %04d: %v", m.Number, err) + } + } + a, b := shape(t, fresh), shape(t, stepped) + if !reflect.DeepEqual(a, b) || len(a) < 25 { + t.Fatalf("the consolidated schema and the migrations differ:\nconsolidated %v\nmigrations %v", a, b) + } + if !strings.Contains(consolidated, "through migration 0001") || len(migrations) != 1 { + t.Fatal("the consolidated schema does not say how far it is consolidated") + } + // Prepared twice, it changes nothing; and it is ready. + if said, err := fresh.Prepare(ctx); err != nil || !strings.Contains(said[0], "as this version needs") { + t.Fatalf("%v %v", said, err) + } + if err := fresh.Ready(ctx); err != nil { + t.Fatal(err) + } + if err := stepped.Ready(ctx); err == nil { + t.Fatal("a database whose migrations are not recorded is ready") + } +} + +func shape(t *testing.T, s *PgStore) []string { + t.Helper() + rows, err := s.pool.Query(context.Background(), `select table_name || '.' || column_name || ' ' || data_type + from information_schema.columns where table_schema = 'public' and table_name <> 'migration' order by 1`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var c string + _ = rows.Scan(&c) + out = append(out, c) + } + return out +} + +// The search finds by every word, across the accounts asked only, newest first, and answers no body. +func TestTheIndexSearchesByWordAndAccount(t *testing.T) { + eachStore(t, func(t *testing.T, st Store) { + ctx := context.Background() + for i, subj := range []string{"the invoice of october", "lunch", "another invoice"} { + m := Message{Account: "mesh", Folder: "INBOX", UID: uint32(i + 1), Subject: subj, Date: judgedAt.AddDate(0, 0, i), + From: []Person{{Address: "bob@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "text " + subj} + if err := st.Add(ctx, m, nil); err != nil { + t.Fatal(err) + } + } + other := Message{Account: "gmail", Folder: "INBOX", UID: 1, Subject: "invoice elsewhere", Date: judgedAt, From: []Person{}, To: []Person{}, + Flags: []string{}, Attachments: []AttachmentRef{}} + if err := st.Add(ctx, other, nil); err != nil { + t.Fatal(err) + } + hits, err := st.Search(ctx, Query{Text: "invoice", Accounts: []string{"mesh"}, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(hits) != 2 || hits[0].Subject != "another invoice" || hits[0].Body != "" { + t.Fatalf("%+v", hits) + } + if hits, _ := st.Search(ctx, Query{Text: "invoice october", Accounts: []string{"mesh"}}); len(hits) != 1 { + t.Fatalf("%+v", hits) + } + if hits, _ := st.Search(ctx, Query{Text: "bob", Accounts: []string{"mesh"}}); len(hits) != 3 { + t.Fatalf("by sender: %+v", hits) + } + got, ok, err := st.Get(ctx, "mesh", "INBOX", 2) + if err != nil || !ok || got.Body != "text lunch" { + t.Fatalf("%+v %v %v", got, ok, err) + } + }) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/schema.sql b/modules/mesh-mail/cmd/mesh-mail/schema.sql new file mode 100644 index 00000000..f332e0ca --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/schema.sql @@ -0,0 +1,46 @@ +-- The consolidated schema, through migration 0001: what a fresh database is given at once (the mesh's rule: a +-- numbered migration for the running store, and this for a fresh install). +create table folder ( + account text not null, + name text not null, + uidvalidity bigint not null, + messages integer not null default 0, + synced timestamptz not null default now(), + primary key (account, name)); + +create table message ( + account text not null, + folder text not null, + uid bigint not null, + uidvalidity bigint not null, + message_id text not null default '', + in_reply_to text not null default '', + date timestamptz not null, + sender jsonb not null default '[]', + recipients jsonb not null default '[]', + copied jsonb not null default '[]', + people text not null default '', + subject text not null default '', + size bigint not null default 0, + flags text[] not null default '{}', + attachments jsonb not null default '[]', + body text not null default '', + search tsvector generated always as ( + to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored, + primary key (account, folder, uid)); +create index message_search on message using gin (search); +create index message_date on message (account, date desc); + +create table account_state ( + account text primary key, + state text not null, + why text not null default '', + heard timestamptz, + synced timestamptz, + said timestamptz not null default now()); + +create table outbox ( + id bigserial primary key, + key text not null, + body jsonb not null, + made timestamptz not null default now()); diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go new file mode 100644 index 00000000..bd8f31c6 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -0,0 +1,793 @@ +package main + +// mail_send (novox/hq ADR 0361 §2): the tool that replaces the no-send rule of ADR 0359 §5, for sending alone. It +// sends nothing itself. A caller granted the account (the same check as mail_read, ADR 0359 §4) asks for one send; +// this raises an ask on the operator channel (novox/hq ADR 0259) showing the account, the recipients, the subject +// and the first lines of the body, and the tool returns at once with the ask's id. The mail reaches the account's +// outgoing server only on the operator's Approve, on a channel that proves who answered, and once: the id is kept +// with the decision it was given, so a warrant delivered twice is acted on once (asks.Warrant.For and +// asks.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it +// expires, sends nothing, which mail_send_status says. The password is read only to sign in, and the body only to +// send; neither is ever logged. +// +// The pattern — publish an ask under this module's own name, compose it again from what was kept to hold the +// router's warrant, act once on a compare-and-set claim — follows mesh-knowledge's own approval of a record +// (cmd/mesh-knowledge/operator.go) and claude-code's proposals and questions (cmd/claude-code/proposals.go, +// questions.go). + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/mail" + "regexp" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// SendsState is the module's state the sends are kept in. +const SendsState = "sends" + +// The two answers, both at the level approve, so only a channel that proves who answered can carry either. +const ( + OptionSend = "send" + OptionDecline = "decline" +) + +// SendAskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval. +const SendAskLasts = asks.ApproveLasts - 10*time.Minute + +// StaleSendAfter is how long a send may read "sending" before mail_send_status stops calling it "waiting": past +// this, the claim before the SMTP call is old enough that the send may or may not have gone out, and is never +// retried automatically (novox/hq issue 790, review round 1). +const StaleSendAfter = 10 * time.Minute + +// SweepEvery is how often the sweep reads every send still undecided and asks the router's own record for its +// answer, so an Approve answered while this module did not hear it directly (down, or not yet subscribed) is +// still acted on (novox/hq issue 790, review round 1). +const SweepEvery = time.Minute + +// undecidedIndexKey is a reserved key in the `sends` state (never a send id, which always starts "send-") that +// keeps the ids of the sends still undecided, so the sweep can list only those instead of every send ever kept +// (novox/hq issue 811). It stays in the same declared state rather than a state of its own, which a manifest +// change would need. +const undecidedIndexKey = "undecided-index" + +// undecidedIndex is the undecidedIndexKey document: the ids of the sends still undecided. +type undecidedIndex struct { + IDs []string `json:"ids"` +} + +// addUndecided adds id to the undecided index, retrying its compare-and-set write on a race. A stored index that +// cannot be parsed is never overwritten (which would silently drop every id it already named): it is reported as +// an error naming the key instead (novox/hq issue 811, review round 3). +func addUndecided(store SendStore, id string) error { + for { + raw, rev, ok, err := store.Get(undecidedIndexKey) + if err != nil { + return err + } + var idx undecidedIndex + if ok { + if err := json.Unmarshal(raw, &idx); err != nil { + return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) + } + for _, existing := range idx.IDs { + if existing == id { + return nil + } + } + } + idx.IDs = append(idx.IDs, id) + if !ok { + if _, err := store.Create(undecidedIndexKey, idx); err != nil { + if errors.Is(err, stdio.ErrChanged) { + continue + } + return err + } + return nil + } + if _, err := store.Update(undecidedIndexKey, idx, rev); err != nil { + if errors.Is(err, stdio.ErrChanged) { + continue + } + return err + } + return nil + } +} + +// removeUndecided drops id from the undecided index, retrying its compare-and-set write on a race. A send never +// read by the index (already absent) is left as it is. A stored index that cannot be parsed is never overwritten: +// it is reported as an error naming the key instead (novox/hq issue 811, review round 3). +func removeUndecided(store SendStore, id string) error { + for { + raw, rev, ok, err := store.Get(undecidedIndexKey) + if err != nil || !ok { + return err + } + var idx undecidedIndex + if err := json.Unmarshal(raw, &idx); err != nil { + return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) + } + at := -1 + for i, existing := range idx.IDs { + if existing == id { + at = i + break + } + } + if at == -1 { + return nil + } + idx.IDs = append(idx.IDs[:at], idx.IDs[at+1:]...) + if _, err := store.Update(undecidedIndexKey, idx, rev); err != nil { + if errors.Is(err, stdio.ErrChanged) { + continue + } + return err + } + return nil + } +} + +// undecidedIDs is every id the undecided index currently names, for the sweep to read. An index that cannot be +// parsed is reported as an error naming the key, not silently read as empty (novox/hq issue 811, review round 3). +func undecidedIDs(store SendStore) ([]string, error) { + raw, _, ok, err := store.Get(undecidedIndexKey) + if err != nil || !ok { + return nil, err + } + var idx undecidedIndex + if err := json.Unmarshal(raw, &idx); err != nil { + return nil, fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) + } + return idx.IDs, nil +} + +// The bounds of what mail_send takes. +const ( + MostRecipients = 20 + MostSubject = 200 + MostSendBody = 1 << 20 // 1 MiB; only the first ShownBodyChars reach the operator's ask + ShownBodyChars = 300 +) + +// SendRequest is one send asked of the operator, as the `sends` state keeps it. Every field the ask composes from +// is here, so the decision is held to exactly what was asked. +type SendRequest struct { + ID string `json:"id"` + Account string `json:"account"` + To []string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Caller string `json:"caller"` + At time.Time `json:"at"` + // AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq + // issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's + // name, any other said without its address or host. Composed once, from the accounts configured when + // the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide, + // mail_send_status) as it did when the operator was first asked. + AskTo string `json:"ask_to"` + // Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2). + Decided *SendDecision `json:"decided,omitempty"` +} + +// SendDecision is what became of one send, kept once and read back by mail_send_status. +type SendDecision struct { + At time.Time `json:"at"` + Outcome string `json:"outcome"` + Warrant string `json:"warrant,omitempty"` + Error string `json:"error,omitempty"` +} + +// bodyDigest is the digest an act binds in place of the body itself: never the body, which the act's digest is +// published and read back on the bus. +func bodyDigest(body string) string { + sum := sha256.Sum256([]byte(body)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Act is what an answer binds: this request, whole, and the option chosen. +func (s SendRequest) Act(option string) asks.Act { + return asks.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account, + "to": strings.Join(s.To, ","), "subject": s.Subject, "body-digest": bodyDigest(s.Body), + "caller": s.Caller, "at": s.At.UTC().Format(time.RFC3339Nano)} +} + +// firstChars is at most n runes of s, marked when it was cut. +func firstChars(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + " […cut]" +} + +// Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first +// lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets +// through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and +// body have every mail address and link or host name masked (maskText) before they reach the ask. Messenger +// refuses an ask as a whole on its Headline, Explanation, OnExpiry and each option's Label and Does alone +// (asks.go's TakeAsk) — never Details, which it filters line by line instead (holder.go's sayOn) — so once +// those are composed, the real content rule (content_rule.go, mesh-mail's own run-time copy) judges them +// exactly as messenger would. When the masking still leaves something the rule refuses (an IP, a hardware +// address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first +// lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator. +// The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body. +func (s SendRequest) Ask() asks.Ask { + to := s.AskTo + subject := maskText(s.Subject) + body := maskText(firstChars(s.Body, ShownBodyChars)) + headline := fmt.Sprintf("Send mail from %s?", s.Account) + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Send mail?" + } + onExpiry := "nothing is sent" + options := []struct{ id, label, does string }{ + {OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"}, + {OptionDecline, "Decline", "nothing is sent"}, + } + // judged is exactly the words messenger refuses (or lets through) an ask as a whole on. + judged := func(explanation string) string { + words := []string{headline, explanation, onExpiry} + for _, o := range options { + words = append(words, o.label, o.does) + } + return strings.Join(words, "\n") + } + explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject) + if len([]rune(explanation)) > asks.SummaryLength { + explanation = fmt.Sprintf("From %s: %q.", s.Account, subject) + } + if _, ok := Check(judged(explanation)); !ok { + explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to) + if len([]rune(explanation)) > asks.SummaryLength { + explanation = "Send mail. The subject and first lines are in Details." + } + } + details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s", + s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body) + a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details, + Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry} + for _, o := range options { + binds, _ := asks.ActDigest(s.Act(o.id)) + a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + } + return a +} + +func newSendID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return "send-" + hex.EncodeToString(b[:]) +} + +// ---- the bus, as a send reaches it ---------------------------------------------------------------------------- + +// AskBus is the operator channel as this module reaches it: publish an ask under its own name, and read the +// router's own record of one of its asks. +type AskBus interface { + Publish(subject string, body any, id string) error + Record(id string) (*stdio.StateEntry, error) +} + +type sdkAskBus struct{} + +func (sdkAskBus) Publish(subject string, body any, id string) error { + return stdio.SeatPublish(subject, body, id) +} +func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) } + +// SendStore is the `sends` state: a value with its revision, created only when absent, updated only at the revision +// read — so of two writers only the first writes (the compare-and-set a decision is claimed by). +type SendStore interface { + Get(key string) (json.RawMessage, uint64, bool, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) + Keys() ([]string, error) +} + +// keptSends is the `sends` state on the bus. +type keptSends struct{ s stdio.KeptState } + +func (k keptSends) Get(key string) (json.RawMessage, uint64, bool, error) { + e, err := k.s.Get(key) + if err != nil || e == nil { + return nil, 0, false, err + } + return e.Value, e.Revision, true, nil +} +func (k keptSends) Create(key string, value any) (uint64, error) { return k.s.Create(key, value) } +func (k keptSends) Update(key string, value any, revision uint64) (uint64, error) { + return k.s.Update(key, value, revision) +} +func (k keptSends) Keys() ([]string, error) { return k.s.Keys() } + +func getSend(store SendStore, id string) (SendRequest, uint64, bool, error) { + raw, rev, ok, err := store.Get(id) + if err != nil || !ok { + return SendRequest{}, 0, false, err + } + var req SendRequest + if json.Unmarshal(raw, &req) != nil || req.ID != id { + return SendRequest{}, 0, false, nil + } + return req, rev, true, nil +} + +// routerRecord is the router's record of one ask, as far as this module reads it. +type routerRecord struct { + State string `json:"state"` + Warrant *asks.Warrant `json:"warrant"` +} + +// ---- asking ---------------------------------------------------------------------------------------------------- + +// SendInput is one send a caller asks for: the account is already the caller's to use (the grants check, the same +// as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has +// configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know +// which recipients are the operator's own (novox/hq issue 868). +type SendInput struct { + Account Account + To []string + Subject string + Body string + Caller string + Accounts []Account +} + +// cleanAddresses trims and drops empty entries. +func cleanAddresses(in []string) []string { + var out []string + for _, a := range in { + if a = strings.TrimSpace(a); a != "" { + out = append(out, a) + } + } + return out +} + +// hasControlChar is true when s holds a byte a mail header must never carry: CR, LF, any other ASCII control +// character below 0x20, or DEL (0x7f) — exactly what a header injection needs (novox/hq issue 790, review round 1). +func hasControlChar(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +// AskMailSend raises an ask for one send and keeps it, under the module's own name. It never sends: the mail +// reaches the server only once the operator's Approve is read back and held to this request (Decide). It answers +// at once, with the ask's id; mail_send_status says where it stands. +func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[string]any, error) { + refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was sent") } + to := cleanAddresses(in.To) + switch { + case len(to) == 0: + return refuse("to names no recipient") + case len(to) > MostRecipients: + return refuse(fmt.Sprintf("to names %d recipients, more than %d", len(to), MostRecipients)) + } + for _, addr := range to { + if hasControlChar(addr) { + return refuse(fmt.Sprintf("%q is not a mail address: it holds a control character", addr)) + } + if _, err := mail.ParseAddress(addr); err != nil { + return refuse(fmt.Sprintf("%q is not a mail address", addr)) + } + } + subject := strings.TrimSpace(in.Subject) + switch { + case subject == "": + return refuse("subject is empty") + case hasControlChar(subject): + return refuse("the subject holds a control character") + case len([]rune(subject)) > MostSubject: + return refuse(fmt.Sprintf("the subject is %d characters, more than %d", len([]rune(subject)), MostSubject)) + } + switch { + case strings.TrimSpace(in.Body) == "": + return refuse("body is empty") + case len(in.Body) > MostSendBody: + return refuse(fmt.Sprintf("the body is %d bytes, more than %d", len(in.Body), MostSendBody)) + } + if in.Account.Outgoing == nil { + return refuse(fmt.Sprintf("the account %s has no outgoing server configured", in.Account.Name)) + } + req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body, + Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)} + ask := req.Ask() + if err := ask.Check(now); err != nil { + return refuse(err.Error()) + } + if _, err := store.Create(req.ID, req); err != nil { + return nil, fmt.Errorf("the send could not be kept, so the operator was not asked: %w. Nothing was sent", err) + } + if err := addUndecided(store, req.ID); err != nil { + // Nothing was published yet, so refusing here costs nothing: an ask the sweep could never see is never + // sent (novox/hq issue 811, review round 3). + return nil, fmt.Errorf("the send could not be made visible to the sweep, so the operator was not asked: %w. Nothing was sent", err) + } + if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { + return nil, fmt.Errorf("the operator could not be asked (%v). Nothing was sent; mail_send_status %s says so", err, req.ID) + } + return map[string]any{ + "ask": req.ID, + "status": "waiting", + "asked": "\"" + ask.Headline + "\", to " + to[0] + onwards(to), + "from": in.Account.Name, + "expires": ask.Expires.Format(time.RFC3339), + "next": "mail_send_status with this ask id says where it stands; nothing is sent until the operator's Approve, " + + "and nothing on Decline or at expiry", + }, nil +} + +func onwards(to []string) string { + if len(to) <= 1 { + return "" + } + return fmt.Sprintf(" and %d more", len(to)-1) +} + +// ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------ +// +// messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because +// Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the +// subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead: +// a recipient of the operator's own is named by its account; any other loses its address and host; the subject +// and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ. + +// mostShownRecipients is how many recipients the ask names one by one before it counts the rest. +const mostShownRecipients = 3 + +// composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your +// account "; any other is said without its address or host (its first letter, an ellipsis, and its +// domain's labels as words); past the first few, the rest are counted rather than named. +func composeAskTo(to []string, accounts []Account) string { + own := map[string]string{} + for _, a := range accounts { + if a.Address != "" { + own[strings.ToLower(a.Address)] = a.Name + } + } + shown, more := to, 0 + if len(shown) > mostShownRecipients { + shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients + } + parts := make([]string, 0, len(shown)) + for _, addr := range shown { + parts = append(parts, oneAskRecipient(addr, own)) + } + line := strings.Join(parts, ", ") + if more > 0 { + line += fmt.Sprintf(" and %d more", more) + } + return line +} + +// oneAskRecipient is one recipient's words in the ask: "your account " for one of the accounts given, or +// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). A +// recipient written with a display name ("Name ") is matched and masked by its address alone, parsed the +// same way AskMailSend already validates it (novox/hq issue 868, review round 1). +func oneAskRecipient(addr string, own map[string]string) string { + bare := addr + if parsed, err := mail.ParseAddress(addr); err == nil { + bare = parsed.Address + } + if name, ok := own[strings.ToLower(bare)]; ok { + return "your account " + name + } + local, domain, found := strings.Cut(bare, "@") + if !found || local == "" { + return "a recipient" + } + first := string([]rune(local)[:1]) + return first + "… at " + strings.ReplaceAll(domain, ".", " ") +} + +// maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL), +// so what they catch here is masked before the rule ever judges it. +var ( + maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`) + // maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators), + // so a bare host name is found on the same tokens the rule itself would judge. + maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`) +) + +// maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list +// content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused. +var maskTopLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + maskTopLevel[t] = true + } +} + +// maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the +// subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves: +// the mail is sent with what was actually written (novox/hq issue 868). +func maskText(s string) string { + s = maskEmail.ReplaceAllString(s, "[address]") + s = maskURL.ReplaceAllString(s, "[link]") + return maskWord.ReplaceAllStringFunc(s, func(word string) string { + trimmed := strings.TrimRight(word, ".,:;!?") + if looksLikeHostName(trimmed) { + return "[link]" + word[len(trimmed):] + } + return word + }) +} + +// looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName +// judges a bare host name by. +func looksLikeHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return maskTopLevel[parts[len(parts)-1]] +} + +// ---- deciding --------------------------------------------------------------------------------------------------- + +// AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked: +// the account's server may have changed since, and its password is read fresh), or found false. +type AccountLookup func(name string) (Account, bool) + +// SendActor performs the one send a chosen option authorises, resolving the account's own secret itself. It is +// called at most once per request (Decide claims the request first). +type SendActor func(a Account, to []string, subject, body string, now time.Time) error + +// settleSend writes a decision onto a kept send (claiming it, or ending it outright) and, once the write lands, +// drops it from the undecided index: an index entry is never left behind once the record it points to carries a +// decision (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided. +func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) { + next := req + next.Decided = &d + newRev, err := store.Update(req.ID, next, rev) + if err != nil { + if errors.Is(err, stdio.ErrChanged) { + if again, _, ok, err2 := getSend(store, req.ID); err2 == nil && ok && again.Decided != nil { + return *again.Decided, 0, nil + } + } + return d, 0, err + } + _ = removeUndecided(store, req.ID) // best-effort (see removeUndecided) + return d, newRev, nil +} + +// Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and — +// on Approve, exactly once — performs the send. A request already decided is never decided again: decide answers +// its kept decision at once, and two warrants for the same request (a redelivery, or two readers racing) have only +// the first claim the request by a compare-and-set write; the second finds it already claimed or decided and sends +// nothing. +func Decide(store SendStore, lookup AccountLookup, send SendActor, w asks.Warrant, now time.Time) (SendDecision, error) { + req, rev, ok, err := getSend(store, w.Ask) + if err != nil { + return SendDecision{}, err + } + if !ok { + return SendDecision{}, fmt.Errorf("no send %q is kept", w.Ask) + } + if req.Decided != nil { + return *req.Decided, nil + } + if w.Asker != Module { + return SendDecision{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's", w.Asker, w.Ask, Module) + } + settle := func(d SendDecision) (SendDecision, error) { + final, _, err := settleSend(store, req, rev, d) + return final, err + } + if w.Outcome != asks.OutcomeChosen { + outcome := "ended: " + string(w.Outcome) + if w.Words != "" { + outcome += " (" + w.Words + ")" + } + return settle(SendDecision{At: now, Outcome: outcome}) + } + ask := req.Ask() + option, err := w.For(Module, ask) + if err == nil { + err = option.Performs(req.Act(option.ID)) + } + if err != nil { + return settle(SendDecision{At: now, Outcome: "refused: " + err.Error()}) + } + switch option.ID { + case OptionDecline: + return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()}) + case OptionSend: + return decideSend(store, lookup, send, req, rev, w, now) + default: + return settle(SendDecision{At: now, Outcome: "refused: the option " + option.ID + " is not one mail_send offers"}) + } +} + +// decideSend claims the request by a compare-and-set write before it sends, so a redelivered warrant finds it +// already claimed (or decided) and acts on nothing: a person's Approve is performed once, never retried on a +// transient failure (the failure is kept in the decision, not hidden). +func decideSend(store SendStore, lookup AccountLookup, send SendActor, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) { + claimed := req + claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says()} + claimedDecision, newRev, err := settleSend(store, req, rev, claim) + if err != nil { + return SendDecision{}, err + } + if newRev == 0 { + // Lost the claim race: another writer already decided this send. + return claimedDecision, nil + } + claimed.Decided = &claim + acc, found := lookup(req.Account) + var final SendDecision + switch { + case !found: + final = SendDecision{At: now, Outcome: "refused: the account no longer exists", Warrant: w.Says()} + default: + if sendErr := send(acc, req.To, req.Subject, req.Body, now); sendErr != nil { + final = SendDecision{At: now, Outcome: "failed: " + sendErr.Error(), Warrant: w.Says(), Error: sendErr.Error()} + } else { + final = SendDecision{At: now, Outcome: "sent", Warrant: w.Says()} + } + } + claimed.Decided = &final + if _, err := store.Update(req.ID, claimed, newRev); err != nil { + // The claim stands either way: a person's Approve is acted on once. The caller logs this; it is not retried. + return final, fmt.Errorf("the send %s was %s, and that could not be kept (%w)", req.ID, final.Outcome, err) + } + return final, nil +} + +// ---- reading the status ----------------------------------------------------------------------------------------- + +// ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, +// declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own +// record; when the router's word is a close one that was not decided yet, this is also where a send is first +// decided (and so sent), exactly once (Decide). +func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send SendActor, id string, now time.Time) (map[string]any, error) { + trimmed := strings.TrimSpace(id) + req, rev, ok, err := getSend(store, trimmed) + if err != nil { + return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err) + } + if !ok { + return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed) + } + out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject, + "asked": req.At.Format(time.RFC3339)} + if req.Decided != nil { + reportDecision(out, *req.Decided, now) + return out, nil + } + entry, err := bus.Record(req.ID) + if err != nil { + return nil, fmt.Errorf("the router's record of the ask cannot be read now (%v); ask again", err) + } + if entry == nil { + // The router never recorded this ask at all (its Publish failed, or it was never heard): once its asked + // time plus SendAskLasts has passed, it never will be, so it is settled as ended rather than left + // "waiting" forever (novox/hq issue 811). + if now.Sub(req.At) > SendAskLasts { + d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"}) + if err != nil { + return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err) + } + reportDecision(out, d, now) + return out, nil + } + out["status"], out["words"] = "waiting", "not yet recorded by the router; nothing is sent until the operator answers" + return out, nil + } + var rec routerRecord + if json.Unmarshal(entry.Value, &rec) != nil { + out["status"], out["words"] = "not proven", "the router's record of it cannot be read" + return out, nil + } + if rec.State == "" || rec.State == "open" || rec.Warrant == nil { + out["status"], out["words"] = "waiting", "the operator has not answered yet" + return out, nil + } + d, err := Decide(store, lookup, send, *rec.Warrant, now) + if err != nil { + out["status"], out["words"] = "not proven", err.Error() + return out, nil + } + reportDecision(out, d, now) + return out, nil +} + +// reportDecision turns a kept decision into what mail_send_status answers: a status word a caller can branch on, +// and the words that explain it. now is when it is asked, not when the send was decided: a claim still "sending" +// longer than StaleSendAfter with no final write is "not proven" (the send may or may not have gone out), never +// "waiting" forever — and it is never retried automatically either way (novox/hq issue 790, review round 1). +func reportDecision(out map[string]any, d SendDecision, now time.Time) { + out["at"] = d.At.Format(time.RFC3339) + switch { + case d.Outcome == "sent": + out["status"], out["words"] = "sent", "sent on the operator's Approve ("+d.Warrant+")" + case d.Outcome == "declined": + out["status"], out["words"] = "declined", "nothing was sent: the operator declined ("+d.Warrant+")" + case d.Outcome == "sending" && now.Sub(d.At) > StaleSendAfter: + out["status"] = "not proven" + out["words"] = "not proven: the send may or may not have gone out; the operator's Approve was being acted " + + "on at " + d.At.Format(time.RFC3339) + ", with no final word since" + case d.Outcome == "sending": + out["status"], out["words"] = "waiting", "the operator's Approve is being acted on; ask again shortly" + case d.Outcome == "ended: never heard": + out["status"], out["words"] = "ended", "its ask to the operator was never heard, so it was not sent" + case strings.HasPrefix(d.Outcome, "failed: "): + out["status"], out["words"] = "failed", "nothing reached the server: "+strings.TrimPrefix(d.Outcome, "failed: ") + case strings.HasPrefix(d.Outcome, "ended: "): + out["status"], out["words"] = "ended", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ") + case strings.HasPrefix(d.Outcome, "refused: "): + out["status"], out["words"] = "not proven", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ") + default: + out["status"], out["words"] = "ended", d.Outcome + } +} + +// ---- heard directly, and swept ------------------------------------------------------------------------------- + +// Heard acts on one warrant the router's own subject delivered to this module directly (main.go's hear), through +// the same Decide that mail_send_status uses: an approved send goes out as soon as the operator answers, never +// only when something later happens to call mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790). +func (b *Bundle) Heard(w asks.Warrant) { + if _, err := Decide(b.sends(), b.lookupAccount, b.sendActor(), w, b.now()); err != nil { + if b.Logf != nil { + b.Logf("[mesh-mail] the warrant for the send %s could not be acted on: %v", w.Ask, err) + } + } +} + +// Sweep reads only the sends the undecided index still names, and asks the router's own record for each one's +// answer, the same path mail_send_status takes (ReadSendStatus): an Approve recorded while this module did not +// hear it directly (down, or not yet subscribed) is still acted on, once. Unlike reading every send ever kept, its +// work grows only with the sends still undecided, never with every one a module has ever sent (novox/hq ADR 0361 +// §2, review round 1 of issue 790; the index, issue 811). +func (b *Bundle) Sweep(now time.Time) { + store := b.sends() + ids, err := undecidedIDs(store) + if err != nil { + if b.Logf != nil { + b.Logf("[mesh-mail] the undecided index cannot be read: %v", err) + } + return + } + for _, id := range ids { + req, _, ok, err := getSend(store, id) + if err != nil || !ok || req.Decided != nil { + continue + } + if _, err := ReadSendStatus(store, b.askBus(), b.lookupAccount, b.sendActor(), id, now); err != nil { + if b.Logf != nil { + b.Logf("[mesh-mail] sweeping the send %s: %v", id, err) + } + } + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go new file mode 100644 index 00000000..176a7287 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -0,0 +1,750 @@ +package main + +// mail_send (novox/hq ADR 0361 §2): an ungranted caller is refused before anything is asked; a granted caller's ask +// holds the account, the recipients, the subject and the first lines; the operator's Approve sends through an +// in-process fake SMTP server exactly once, even delivered twice; a Decline sends nothing. + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "net" + "net/smtp" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// ---- a fake `sends` state and a fake operator channel, as a test keeps them -------------------------------------- + +type memSends struct { + mu sync.Mutex + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + + // failKey/failErr: when failKey matches, Create and Update report failErr instead of writing, for a test to + // simulate a store whose index write fails (novox/hq issue 811, review round 3). + failKey string + failErr error +} + +func newMemSends() *memSends { + return &memSends{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} +} + +func (m *memSends) Get(key string) (json.RawMessage, uint64, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.vals[key] + if !ok { + return nil, 0, false, nil + } + return v, m.revs[key], true, nil +} +func (m *memSends) Create(key string, value any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.failErr != nil && key == m.failKey { + return 0, m.failErr + } + if _, ok := m.vals[key]; ok { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} +func (m *memSends) Update(key string, value any, revision uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.failErr != nil && key == m.failKey { + return 0, m.failErr + } + if revision == 0 || m.revs[key] != revision { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} +func (m *memSends) put(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + m.n++ + m.vals[key], m.revs[key] = raw, m.n + return m.n, nil +} +func (m *memSends) Keys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]string, 0, len(m.vals)) + for k := range m.vals { + out = append(out, k) + } + return out, nil +} + +// sendRouter is the operator channel's holder as this module reaches it, kept by a test: what was asked, and the +// router's own record of each ask. +type sendRouter struct { + mu sync.Mutex + published map[string]asks.Ask + records map[string]json.RawMessage + publishErr error + recordCalls map[string]int +} + +func newSendRouter() *sendRouter { + return &sendRouter{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}} +} + +func (r *sendRouter) Publish(subject string, body any, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.publishErr != nil { + return r.publishErr + } + raw, _ := json.Marshal(body) + var a asks.Ask + _ = json.Unmarshal(raw, &a) + r.published[a.ID] = a + r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"}) + return nil +} + +// recordCallsFor is how many times Record was asked about id since the last resetRecordCalls, for a test to check +// the sweep read only the sends still undecided (novox/hq issue 811). +func (r *sendRouter) recordCallsFor(id string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.recordCalls[id] +} + +func (r *sendRouter) resetRecordCalls() { + r.mu.Lock() + defer r.mu.Unlock() + r.recordCalls = map[string]int{} +} + +func (r *sendRouter) Record(id string) (*stdio.StateEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.recordCalls[id]++ + raw, ok := r.records[id] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: id, Value: raw}, nil +} + +// close ends an ask with a warrant, as the router records it. +func (r *sendRouter) close(w asks.Warrant) { + r.mu.Lock() + defer r.mu.Unlock() + r.records[w.Ask], _ = json.Marshal(map[string]any{"state": string(w.Outcome), "warrant": w}) +} + +// chose is the warrant for a person's choice of an option of the ask as the router took it. +func (r *sendRouter) chose(id, option string, at time.Time) asks.Warrant { + r.mu.Lock() + a := r.published[id] + r.mu.Unlock() + o, _ := a.Option(option) + return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, + By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()} +} + +func (r *sendRouter) published1() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.published) +} + +// ---- an in-process fake SMTP server, speaking the protocol over a real socket ------------------------------------- + +type receivedMail struct { + from string + to []string + data string +} + +type fakeSMTPServer struct { + ln net.Listener + + mu sync.Mutex + received []receivedMail + refuse bool // when true, DATA is refused: a transient server error +} + +func newFakeSMTPServer(t *testing.T) *fakeSMTPServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + f := &fakeSMTPServer{ln: ln} + go f.serve() + t.Cleanup(func() { _ = ln.Close() }) + return f +} + +func (f *fakeSMTPServer) addr() string { return f.ln.Addr().String() } + +// dialer reaches the fake server directly, plain: only a test skips the TLS the mesh's own dialer always uses. +func (f *fakeSMTPServer) dialer() SMTPDialer { + addr := f.addr() + return func(Server) (*smtp.Client, error) { return smtp.Dial(addr) } +} + +func (f *fakeSMTPServer) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.received) +} + +func (f *fakeSMTPServer) last() receivedMail { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.received) == 0 { + return receivedMail{} + } + return f.received[len(f.received)-1] +} + +func (f *fakeSMTPServer) serve() { + for { + conn, err := f.ln.Accept() + if err != nil { + return + } + go f.handle(conn) + } +} + +func (f *fakeSMTPServer) handle(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + reply := func(code int, msg string) { fmt.Fprintf(conn, "%d %s\r\n", code, msg) } + reply(220, "fake.local ESMTP") + var from string + var to []string + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + reply(250, "fake.local") + case strings.HasPrefix(upper, "AUTH"): + reply(235, "authenticated") + case strings.HasPrefix(upper, "MAIL FROM:"): + from = addrIn(line) + reply(250, "OK") + case strings.HasPrefix(upper, "RCPT TO:"): + to = append(to, addrIn(line)) + reply(250, "OK") + case upper == "DATA": + f.mu.Lock() + refuse := f.refuse + f.mu.Unlock() + if refuse { + reply(451, "temporary failure") + continue + } + reply(354, "End data with .") + var b strings.Builder + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if dl == ".\r\n" || dl == ".\n" { + break + } + if strings.HasPrefix(dl, "..") { + dl = dl[1:] + } + b.WriteString(dl) + } + f.mu.Lock() + f.received = append(f.received, receivedMail{from: from, to: append([]string{}, to...), data: b.String()}) + f.mu.Unlock() + reply(250, "OK: queued") + from, to = "", nil + case upper == "RSET": + reply(250, "OK") + from, to = "", nil + case upper == "QUIT": + reply(221, "bye") + return + default: + reply(500, "unrecognized command") + } + } +} + +func addrIn(line string) string { + start, end := strings.Index(line, "<"), strings.Index(line, ">") + if start >= 0 && end > start { + return line[start+1 : end] + } + return line +} + +// ---- a bundle over an account with an outgoing server --------------------------------------------------------- + +// sendTestBundle is a bundle over one account, "mesh", granted to "triage" alone, with an outgoing server of the +// caller's choosing (a fake SMTP server's, in most tests) and its password already given. +func sendTestBundle(t *testing.T, outgoing Server) (*Bundle, *sendRouter, *memSends) { + t.Helper() + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir} + writeFile(t, env.Accounts, fmt.Sprintf(`{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be", + "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}, + "outgoing": {"host": %q, "port": %d, "security": %q}}}}`, outgoing.Host, outgoing.Port, outgoing.Security)) + writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`) + giveSecret(t, dir, Account{Name: "mesh"}, "the-password") + router, sends := newSendRouter(), newMemSends() + b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends} + return b, router, sends +} + +var sendArgs = map[string]any{"account": "mesh", "to": []any{"ops@example.org", "lead@example.org"}, + "subject": "The nightly gate is red", "body": "The merge gate failed on the resolver change.\nSee the job log."} + +// (1) An ungranted caller is refused, and no ask is raised. +func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.other", sendArgs) + if !isErr || !strings.Contains(text, "refused") || !strings.Contains(text, "mesh") { + t.Fatalf("%v %s", isErr, text) + } + if n := router.published1(); n != 0 { + t.Fatalf("an ask was raised for an ungranted caller: %d", n) + } +} + +// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first +// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3) +// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one). +func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + if json.Unmarshal([]byte(text), &out) != nil { + t.Fatalf("not JSON: %s", text) + } + id, _ := out["ask"].(string) + if id == "" || out["status"] != "waiting" { + t.Fatalf("%s", text) + } + if n := router.published1(); n != 1 { + t.Fatalf("published %d asks, not 1", n) + } + a := router.published[id] + if err := a.Check(judgedAt); err != nil { + t.Errorf("the ask does not pass the asks contract: %v", err) + } + whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details + for _, want := range []string{"mesh", "o… at example org", "l… at example org", "The nightly gate is red", "The merge gate failed on the resolver change"} { + if !strings.Contains(whole, want) { + t.Errorf("the ask does not say %q:\n%s", want, whole) + } + } + if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") { + t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole) + } + for _, o := range a.Options { + if o.Level != asks.Approve || o.Binds == "" { + t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds) + } + } +} + +// (3) On Approve, the fake SMTP server receives exactly one message, even if the warrant is delivered twice. +func TestOnApproveTheFakeSMTPServerReceivesExactlyOneMessageEvenDeliveredTwice(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionSend, judgedAt.Add(time.Minute)) + router.close(w) + + lookup := b.lookupAccount + actor := b.sendActor() + if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(2*time.Minute)); err != nil { + t.Fatalf("deciding: %v", err) + } + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after one Approve", n) + } + // The warrant delivered a second time: still exactly one message. + if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(3*time.Minute)); err != nil { + t.Fatalf("deciding again: %v", err) + } + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after the warrant was delivered twice", n) + } + got := fake.last() + if got.from != "jschoubben@novox.be" || len(got.to) != 2 || got.to[0] != "ops@example.org" || got.to[1] != "lead@example.org" { + t.Fatalf("received %+v", got) + } + if !strings.Contains(got.data, "The nightly gate is red") || !strings.Contains(got.data, "The merge gate failed on the resolver change") { + t.Fatalf("the message sent: %q", got.data) + } + + // mail_send_status reports it sent, and reports the same thing again without sending a third time. + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr || !strings.Contains(text, `"status":"sent"`) { + t.Fatalf("%v %s", isErr, text) + } + if n := fake.count(); n != 1 { + t.Fatalf("mail_send_status sent again: %d", n) + } +} + +// (4) On Decline, nothing is sent, and the status says declined. +func TestOnDeclineNothingIsSentAndTheStatusSaysDeclined(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionDecline, judgedAt.Add(time.Minute)) + router.close(w) + if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil { + t.Fatalf("deciding: %v", err) + } + if n := fake.count(); n != 0 { + t.Fatalf("the fake server received %d message(s) on Decline", n) + } + + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr || !strings.Contains(text, `"status":"declined"`) { + t.Fatalf("%v %s", isErr, text) + } +} + +// An account with no outgoing server is refused before anything is asked. +func TestMailSendRefusesAnAccountWithNoOutgoingServer(t *testing.T) { + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir} + writeFile(t, env.Accounts, stageOne) // no outgoing server + writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`) + router := newSendRouter() + b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: newMemSends()} + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if !isErr || !strings.Contains(text, "no outgoing server") { + t.Fatalf("%v %s", isErr, text) + } + if n := router.published1(); n != 0 { + t.Fatalf("an ask was raised for an account with no outgoing server: %d", n) + } +} + +// (5) An approved send goes out once the warrant reaches the module through the hear handler alone — never +// through mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790). +func TestAnApprovedSendGoesOutOnceWithNoStatusCall(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionSend, judgedAt.Add(time.Minute)) + router.close(w) + + b.Heard(w) // the warrant delivered through the hear handler only + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after the warrant was heard once", n) + } + b.Heard(w) // the same warrant delivered a second time + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after the warrant was heard twice", n) + } +} + +// (6) The sweep acts on a send the operator approved while this module was never told directly: it reads the +// router's own record, the same path mail_send_status takes (novox/hq issue 790, review round 1). +func TestSweepActsOnAnApprovedSendItWasNotToldAboutDirectly(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionSend, judgedAt.Add(time.Minute)) + router.close(w) // only the router's record holds the answer; mesh-mail is never told directly + + b.Sweep(judgedAt.Add(2 * time.Minute)) + if n := fake.count(); n != 1 { + t.Fatalf("the sweep did not act on the approved send: %d message(s)", n) + } + b.Sweep(judgedAt.Add(3 * time.Minute)) + if n := fake.count(); n != 1 { + t.Fatalf("a second sweep sent again: %d message(s)", n) + } +} + +// (7) A send claimed "sending" longer than StaleSendAfter with no final write reads as "not proven", never +// "waiting" forever (novox/hq issue 790, review round 1). +func TestAStaleSendingClaimIsNotProvenNotWaiting(t *testing.T) { + b, _, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + req, rev, ok, err := getSend(sends, id) + if err != nil || !ok { + t.Fatalf("%v %v", err, ok) + } + claim := SendDecision{At: judgedAt.Add(-11 * time.Minute), Outcome: "sending", Warrant: "W1"} + req.Decided = &claim + if _, err := sends.Update(req.ID, req, rev); err != nil { + t.Fatalf("%v", err) + } + + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr { + t.Fatalf("%s", text) + } + if strings.Contains(text, `"status":"waiting"`) { + t.Fatalf("still reports waiting past the stale bound: %s", text) + } + if !strings.Contains(text, `"status":"not proven"`) { + t.Fatalf("does not report not proven once the claim is stale: %s", text) + } +} + +// (8) The subject and every address are refused when they hold a control character: no header injection +// (novox/hq issue 790, review round 1). +func TestMailSendRefusesAControlCharacterInTheSubjectOrAnAddress(t *testing.T) { + b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + bySubject := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, + "subject": "Report\r\nBcc: attacker@example.org", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", bySubject) + if !isErr || !strings.Contains(text, "control character") { + t.Fatalf("a subject with a control character was not refused: %v %s", isErr, text) + } + + byAddress := map[string]any{"account": "mesh", "to": []any{"ops@example.org\r\nBcc: attacker@example.org"}, + "subject": "Report", "body": "body"} + text, isErr = rt.call(SendTool, "novox.triage", byAddress) + if !isErr || !strings.Contains(text, "control character") { + t.Fatalf("an address with a control character was not refused: %v %s", isErr, text) + } +} + +// (9) Rcpt takes the parsed address alone; the To header takes the formatted address (novox/hq issue 790, review +// round 1). +func TestSendUsesTheParsedAddressForRcptAndTheFormattedAddressForTheToHeader(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"Ops Team "}, + "subject": "Status", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionSend, judgedAt.Add(time.Minute)) + router.close(w) + b.Heard(w) + + got := fake.last() + if len(got.to) != 1 || got.to[0] != "ops@example.org" { + t.Fatalf("RCPT TO got %v, want the parsed address alone", got.to) + } + if !strings.Contains(got.data, `To: "Ops Team" `) { + t.Fatalf("the To header is not the formatted address:\n%s", got.data) + } +} + +// (10) A send whose ask was never published (so the router never recorded it) is settled as ended, with its own +// words, once its asked time plus SendAskLasts has passed — never left "waiting" forever (novox/hq issue 811). +func TestASendWhoseAskNeverPublishedEndsAfterTheAskWouldHaveLasted(t *testing.T) { + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + router.publishErr = errors.New("the bus is down") + rt := startBundle(t, b.Tools()) + + _, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if !isErr { + t.Fatalf("mail_send did not report the publish failure") + } + + // The send was kept despite the publish failure (so a later sweep can still find it). + keys, err := sends.Keys() + if err != nil { + t.Fatalf("%v", err) + } + var id string + for _, k := range keys { + if strings.HasPrefix(k, "send-") { + id = k + } + } + if id == "" { + t.Fatalf("no send was kept despite the publish failure: %v", keys) + } + + // Before the ask would have lasted, it still reads waiting: the router may yet come up and record it. + text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr || !strings.Contains(text, `"status":"waiting"`) { + t.Fatalf("before expiry: %v %s", isErr, text) + } + + // Once a sweep runs after the ask would have lasted, it settles as ended, and nothing was sent. + b.Sweep(judgedAt.Add(SendAskLasts + time.Minute)) + + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr { + t.Fatalf("%s", text) + } + if !strings.Contains(text, `"status":"ended"`) { + t.Fatalf("does not read ended once the ask would have lasted: %s", text) + } + if !strings.Contains(text, "its ask to the operator was never heard, so it was not sent") { + t.Fatalf("does not say why: %s", text) + } +} + +// (11) The sweep reads only the sends still undecided, by the undecided index, not every send ever kept: a send +// already decided is never asked about again (novox/hq issue 811). +func TestSweepReadsOnlyUndecidedSends(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + // One send, declined right away: decided, so it leaves the undecided index. + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + declinedID := out["ask"].(string) + w := router.chose(declinedID, OptionDecline, judgedAt.Add(time.Minute)) + router.close(w) + if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil { + t.Fatalf("deciding: %v", err) + } + + // A second send, left undecided: still waiting on the operator. + text, isErr = rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + _ = json.Unmarshal([]byte(text), &out) + waitingID := out["ask"].(string) + + router.resetRecordCalls() + b.Sweep(judgedAt.Add(3 * time.Minute)) + + if n := router.recordCallsFor(declinedID); n != 0 { + t.Fatalf("the sweep read the decided send's router record %d time(s): it already left the undecided index", n) + } + if n := router.recordCallsFor(waitingID); n == 0 { + t.Fatalf("the sweep never read the undecided send's router record") + } +} + +// (12) When the undecided index cannot be written, mail_send refuses the send and publishes no ask: nothing was +// published yet, so refusing costs nothing, and an ask the sweep could never see is never sent (novox/hq issue +// 811, review round 3). +func TestMailSendRefusesWhenTheUndecidedIndexCannotBeWritten(t *testing.T) { + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + sends.failKey, sends.failErr = undecidedIndexKey, errors.New("the store is down") + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if !isErr { + t.Fatalf("mail_send did not report the undecided index failing to write: %s", text) + } + if n := router.published1(); n != 0 { + t.Fatalf("an ask was published despite the undecided index failing to write: %d", n) + } +} + +// (13) An undecided index that cannot be parsed is never overwritten: addUndecided and removeUndecided fail, +// naming the key, and leave the stored bytes as they were, instead of resetting the index to empty (novox/hq +// issue 811, review round 3). +func TestAddAndRemoveUndecidedFailOnACorruptIndexRatherThanResettingIt(t *testing.T) { + sends := newMemSends() + sends.vals[undecidedIndexKey] = json.RawMessage(`not json`) + sends.revs[undecidedIndexKey] = 1 + + if err := addUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) { + t.Fatalf("addUndecided did not fail naming the key on a corrupt index: %v", err) + } + if string(sends.vals[undecidedIndexKey]) != "not json" { + t.Fatalf("addUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey]) + } + + if err := removeUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) { + t.Fatalf("removeUndecided did not fail naming the key on a corrupt index: %v", err) + } + if string(sends.vals[undecidedIndexKey]) != "not json" { + t.Fatalf("removeUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey]) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/smtp.go b/modules/mesh-mail/cmd/mesh-mail/smtp.go new file mode 100644 index 00000000..bc12d406 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/smtp.go @@ -0,0 +1,139 @@ +package main + +// How mail_send sends, once the operator approved it (novox/hq ADR 0361 §1): through the account's outgoing +// server, implicit TLS from the first byte on 465 or upgraded with STARTTLS on 587, as the account's outgoing +// `security` says — the same two the module already reads for an account's incoming server. The password is the +// own secret mail-credential-, the one IMAP already signs in with; it is read once, used once, and never +// logged, and neither is the body. + +import ( + "crypto/tls" + "fmt" + "mime" + "net/mail" + "net/smtp" + "strings" + "time" + "unicode" +) + +// SMTPDialer opens a connection to an outgoing server, signed in over nothing: implicit TLS on the first byte, or +// plain and then upgraded with STARTTLS, as the server's security says. A test gives a fake dialing a server of its +// own; DialSMTP is the mesh's. +type SMTPDialer func(server Server) (*smtp.Client, error) + +// DialSMTP is the dialer for the mesh (novox/hq ADR 0361 §1). +func DialSMTP(server Server) (*smtp.Client, error) { + switch server.Security { + case "tls": + conn, err := tls.Dial("tcp", server.Address(), &tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return nil, fmt.Errorf("the outgoing server %s cannot be reached over TLS: %w", server.Address(), err) + } + c, err := smtp.NewClient(conn, server.Host) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("the outgoing server %s did not answer as SMTP: %w", server.Address(), err) + } + return c, nil + case "starttls": + c, err := smtp.Dial(server.Address()) + if err != nil { + return nil, fmt.Errorf("the outgoing server %s cannot be reached: %w", server.Address(), err) + } + if ok, _ := c.Extension("STARTTLS"); !ok { + _ = c.Close() + return nil, fmt.Errorf("the outgoing server %s does not offer STARTTLS", server.Address()) + } + if err := c.StartTLS(&tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12}); err != nil { + _ = c.Close() + return nil, fmt.Errorf("STARTTLS to %s failed: %w", server.Address(), err) + } + return c, nil + default: + return nil, fmt.Errorf("the outgoing security %q sends over neither tls nor starttls", server.Security) + } +} + +// SendMail sends one message through the account's outgoing server (novox/hq ADR 0361 §1, §2): signs in with the +// password given, states every recipient, and writes the message once. Never logs the password or the body; an +// error names the server and what it said, never either. +func SendMail(dial SMTPDialer, a Account, password string, to []string, subject, body string, now time.Time) error { + if a.Outgoing == nil { + return fmt.Errorf("the account %s has no outgoing server", a.Name) + } + // Rcpt and the To header take the parsed address, never the raw string a caller gave: the address alone for + // Rcpt, the formatted address for the header (novox/hq issue 790, review round 1; no header injection). + rcpts := make([]string, 0, len(to)) + headers := make([]string, 0, len(to)) + for _, raw := range to { + addr, err := mail.ParseAddress(raw) + if err != nil { + return fmt.Errorf("the recipient %q is not a mail address: %w", raw, err) + } + rcpts = append(rcpts, addr.Address) + headers = append(headers, addr.String()) + } + client, err := dial(*a.Outgoing) + if err != nil { + return err + } + defer client.Close() + from := a.SignInAs() + auth := smtp.PlainAuth("", from, password, a.Outgoing.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("signing in to %s failed: %w", a.Outgoing.Address(), err) + } + if err := client.Mail(from); err != nil { + return fmt.Errorf("the outgoing server refused the sender: %w", err) + } + for _, rcpt := range rcpts { + if err := client.Rcpt(rcpt); err != nil { + return fmt.Errorf("the outgoing server refused the recipient %s: %w", rcpt, err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("the outgoing server refused the message: %w", err) + } + if _, err := w.Write(composeMessage(from, headers, subject, body, now)); err != nil { + return fmt.Errorf("the message could not be written: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("the outgoing server did not accept the message: %w", err) + } + return client.Quit() +} + +// composeMessage is a plain-text message, its lines CRLF-terminated as SMTP takes them. to is the recipients +// already formatted (mail.Address.String()), the same ones Rcpt was given, never the raw strings a caller gave. +func composeMessage(from string, to []string, subject, body string, now time.Time) []byte { + var b strings.Builder + header := func(k, v string) { b.WriteString(k + ": " + v + "\r\n") } + header("From", from) + header("To", strings.Join(to, ", ")) + header("Subject", encodeHeader(subject)) + header("Date", now.Format(time.RFC1123Z)) + header("MIME-Version", "1.0") + header("Content-Type", "text/plain; charset=utf-8") + header("Content-Transfer-Encoding", "8bit") + b.WriteString("\r\n") + b.WriteString(crlf(body)) + return []byte(b.String()) +} + +// encodeHeader word-encodes a header's value when it holds more than ASCII; a header a phone never needs to show. +func encodeHeader(s string) string { + for _, r := range s { + if r > unicode.MaxASCII { + return mime.QEncoding.Encode("UTF-8", s) + } + } + return s +} + +// crlf gives every line break in s a CR, once, the ending SMTP's DATA takes. +func crlf(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\n", "\r\n") +} diff --git a/modules/mesh-mail/cmd/mesh-mail/store.go b/modules/mesh-mail/cmd/mesh-mail/store.go new file mode 100644 index 00000000..7444ddf9 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/store.go @@ -0,0 +1,332 @@ +package main + +// The index (novox/hq ADR 0359 §3): one row per message and folder, in the module's own database; the events still to +// be said on the bus, written in the same step as the change they say; and each account's state as its keeper last +// saw it, which the health watch reads. The Store is that, over postgres (pgstore.go) or in memory for the tests. + +import ( + "context" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Person is a sender or a recipient. +type Person struct { + Name string `json:"name,omitempty"` + Address string `json:"address"` +} + +// AttachmentRef names an attachment by its content (§3): the bucket and key it is kept under, the key being its +// SHA-256 in hex, with its size, media type and file name. +type AttachmentRef struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Size int64 `json:"size"` + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +// Message is one message in one folder of one account, as indexed. +type Message struct { + Account string `json:"account"` + Folder string `json:"folder"` + UIDValidity uint32 `json:"uidvalidity"` + UID uint32 `json:"uid"` + MessageID string `json:"message-id,omitempty"` + InReplyTo string `json:"in-reply-to,omitempty"` + Date time.Time `json:"date"` + From []Person `json:"from"` + To []Person `json:"to"` + Cc []Person `json:"cc,omitempty"` + Subject string `json:"subject"` + Size int64 `json:"size"` + Flags []string `json:"flags"` + Attachments []AttachmentRef `json:"attachments"` + // Body is the message's text, kept for search and served only through mail_read; never on the bus. + Body string `json:"-"` +} + +// Event is one change said on the bus: its key (`mail..message.`) and its body, +// metadata only. +type Event struct { + ID int64 + Key string + Body map[string]any +} + +// Folder is what the index knows of one folder. +type Folder struct { + Account string `json:"-"` + Name string `json:"name"` + UIDValidity uint32 `json:"-"` + Messages int `json:"messages"` + Synced time.Time `json:"synced"` +} + +// The states an account's keeper says it is in. +const ( + AccountSyncing = "syncing" + AccountCurrent = "current" + AccountWaits = "waits-for-sign-in" + AccountFailing = "failing" +) + +// AccountState is an account as its keeper last said it: its state, why, and when the server last answered. +type AccountState struct { + Account string `json:"account"` + State string `json:"state"` + Why string `json:"why"` + Heard time.Time `json:"heard,omitempty"` + Synced time.Time `json:"synced,omitempty"` + Said time.Time `json:"said"` +} + +// Query is a search of the index. +type Query struct { + Text string + Accounts []string + Folder string + Limit int +} + +// Store is the index. +type Store interface { + Ready(ctx context.Context) error + // Folder is what the index knows of a folder; false when it never synced it. + Folder(ctx context.Context, account, folder string) (Folder, bool, error) + // SetFolder records a folder synced. + SetFolder(ctx context.Context, f Folder) error + // DropFolder forgets a folder's rows, without an event: the server renumbered it (a new UIDVALIDITY) and the + // next sync indexes it afresh. + DropFolder(ctx context.Context, account, folder string) error + // Flags is every indexed message of a folder, by UID, with its flags. + Flags(ctx context.Context, account, folder string) (map[uint32][]string, error) + // Add indexes a message, and keeps the event that says it when there is one, in one step. + Add(ctx context.Context, m Message, e *Event) error + // SetFlags changes a message's flags, and keeps the event, in one step. + SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error + // Remove removes a message, and keeps the event, in one step. + Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error + // Get is one message, with its body. + Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error) + // Search finds messages, newest first, without their bodies. + Search(ctx context.Context, q Query) ([]Message, error) + // Folders is every folder the index knows of an account. + Folders(ctx context.Context, account string) ([]Folder, error) + // Pending is the events not yet said, oldest first; Said removes those said. + Pending(ctx context.Context, limit int) ([]Event, error) + Said(ctx context.Context, ids []int64) error + // SetState and States are each account's state, as its keeper says it. + SetState(ctx context.Context, s AccountState) error + States(ctx context.Context) (map[string]AccountState, error) +} + +// ---- in memory ---------------------------------------------------------------------------------------------------- + +type memStore struct { + mu sync.Mutex + messages map[string]Message + folders map[string]Folder + events []Event + next int64 + states map[string]AccountState +} + +func newMemStore() *memStore { + return &memStore{messages: map[string]Message{}, folders: map[string]Folder{}, states: map[string]AccountState{}} +} + +func msgKey(account, folder string, uid uint32) string { + return account + "\x00" + folder + "\x00" + itoa(int64(uid)) +} + +func folderKey(account, folder string) string { return account + "\x00" + folder } + +func (s *memStore) Ready(context.Context) error { return nil } + +func (s *memStore) Folder(_ context.Context, account, folder string) (Folder, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.folders[folderKey(account, folder)] + return f, ok, nil +} + +func (s *memStore) SetFolder(_ context.Context, f Folder) error { + s.mu.Lock() + defer s.mu.Unlock() + s.folders[folderKey(f.Account, f.Name)] = f + return nil +} + +func (s *memStore) DropFolder(_ context.Context, account, folder string) error { + s.mu.Lock() + defer s.mu.Unlock() + for k, m := range s.messages { + if m.Account == account && m.Folder == folder { + delete(s.messages, k) + } + } + delete(s.folders, folderKey(account, folder)) + return nil +} + +func (s *memStore) Flags(_ context.Context, account, folder string) (map[uint32][]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[uint32][]string{} + for _, m := range s.messages { + if m.Account == account && m.Folder == folder { + out[m.UID] = append([]string(nil), m.Flags...) + } + } + return out, nil +} + +func (s *memStore) keep(e *Event) { + if e == nil { + return + } + s.next++ + ev := *e + ev.ID = s.next + s.events = append(s.events, ev) +} + +func (s *memStore) Add(_ context.Context, m Message, e *Event) error { + s.mu.Lock() + defer s.mu.Unlock() + s.messages[msgKey(m.Account, m.Folder, m.UID)] = m + s.keep(e) + return nil +} + +func (s *memStore) SetFlags(_ context.Context, account, folder string, uid uint32, flags []string, e *Event) error { + s.mu.Lock() + defer s.mu.Unlock() + k := msgKey(account, folder, uid) + if m, ok := s.messages[k]; ok { + m.Flags = flags + s.messages[k] = m + } + s.keep(e) + return nil +} + +func (s *memStore) Remove(_ context.Context, account, folder string, uid uint32, e *Event) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.messages, msgKey(account, folder, uid)) + s.keep(e) + return nil +} + +func (s *memStore) Get(_ context.Context, account, folder string, uid uint32) (Message, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.messages[msgKey(account, folder, uid)] + return m, ok, nil +} + +func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + words := strings.Fields(strings.ToLower(q.Text)) + var out []Message + for _, m := range s.messages { + if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) { + continue + } + hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc)) + all := true + for _, w := range words { + all = all && strings.Contains(hay, w) + } + if all { + m.Body = "" + out = append(out, m) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) }) + if q.Limit > 0 && len(out) > q.Limit { + out = out[:q.Limit] + } + return out, nil +} + +func (s *memStore) Folders(_ context.Context, account string) ([]Folder, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []Folder + for _, f := range s.folders { + if f.Account == account { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *memStore) Pending(_ context.Context, limit int) ([]Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + n := min(limit, len(s.events)) + return append([]Event(nil), s.events[:n]...), nil +} + +func (s *memStore) Said(_ context.Context, ids []int64) error { + s.mu.Lock() + defer s.mu.Unlock() + said := map[int64]bool{} + for _, id := range ids { + said[id] = true + } + kept := s.events[:0] + for _, e := range s.events { + if !said[e.ID] { + kept = append(kept, e) + } + } + s.events = kept + return nil +} + +func (s *memStore) SetState(_ context.Context, st AccountState) error { + s.mu.Lock() + defer s.mu.Unlock() + s.states[st.Account] = st + return nil +} + +func (s *memStore) States(context.Context) (map[string]AccountState, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]AccountState{} + for k, v := range s.states { + out[k] = v + } + return out, nil +} + +// ---- small helpers ------------------------------------------------------------------------------------------------ + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func peopleText(ps []Person) string { + var parts []string + for _, p := range ps { + parts = append(parts, strings.TrimSpace(p.Name+" "+p.Address)) + } + return strings.Join(parts, ", ") +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go new file mode 100644 index 00000000..095ee3d4 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -0,0 +1,522 @@ +package main + +// The module's tools (novox/hq ADR 0359 §5, ADR 0361 §2): mail_accounts, mail_search, mail_read, mail_health, +// mail_send and mail_send_status. Who calls is the runtime's word (stdio.CallerOf), never an argument; a caller +// reads an account's mail, or sends from it, only when the trusted setting `grants` names it for that account (§4). +// Nothing here writes to a mailbox; mail_send writes nothing itself either (send.go) — it only ever reaches the +// account's outgoing server on the operator's own Approve. + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tool names, as the manifest declares them. +const ( + AccountsTool = "mail_accounts" + SearchTool = "mail_search" + ReadTool = "mail_read" + SendTool = "mail_send" + SendStatusTool = "mail_send_status" +) + +// Seat tool names: mesh-mail claims the mail-store seat (novox/hq ADR 0378 §2, §3; issue 800 part A) and serves +// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants +// reach the same account's mail through, whichever name it calls. +const ( + SeatAccountsTool = "mail-store.accounts" + SeatSearchTool = "mail-store.search" + SeatReadTool = "mail-store.read" + SeatSendTool = "mail-store.send" +) + +// Bounds of what a tool answers. +const ( + MostResults = 50 + MostQuery = 200 + MostBodyAnswered = 64 << 10 + MostAttachment = 8 << 20 +) + +// Bundle is what the tools reach: the settings, the index, the attachment store, and — for mail_send — the +// operator channel, the `sends` state and the outgoing dialer. The last three are nil outside a test: the real +// bus and dialer (sdkAskBus, keptSends, DialSMTP) are used lazily, so building a Bundle for the read-only tools +// never needs them. +type Bundle struct { + Env Env + Index func(ctx context.Context) (Store, error) + Objects func() (Objects, error) + Caller func(args map[string]any) string + Now func() time.Time + + Asks AskBus + Sends SendStore + Dial SMTPDialer + Logf func(format string, a ...any) +} + +func (b *Bundle) now() time.Time { + if b.Now != nil { + return b.Now() + } + return time.Now() +} + +// settings are the accounts and the grants. A grants setting that cannot be read grants nothing, and its reason is +// kept, so a refusal says why rather than only that the caller is not granted. +func (b *Bundle) settings() ([]Account, access, error) { + accounts, err := ReadAccounts(b.Env.Accounts) + if err != nil { + return nil, access{}, err + } + grants, err := ReadGrants(b.Env.Grants) + if err != nil { + return accounts, access{grants: Grants{}, unread: err}, nil + } + return accounts, access{grants: grants}, nil +} + +// access is the grants as read, and why they could not be, when they could not. +type access struct { + grants Grants + unread error +} + +func (a access) Allows(caller, account string) bool { return a.grants.Allows(caller, account) } + +func (a access) Granted(caller string, accounts []Account) []string { + return a.grants.Granted(caller, accounts) +} + +// refuse is the refusal of a caller for an account, saying why: the grants unread, or the caller not in them. +func (a access) refuse(caller, account string) error { + if a.unread != nil { + return fmt.Errorf("refused: the setting grants cannot be read, so it grants nobody: %v", a.unread) + } + return refused(caller, account) +} + +// Tools are the module's tools. +func (b *Bundle) Tools() []stdio.Tool { + return []stdio.Tool{ + {Name: AccountsTool, + Description: "The operator's mail accounts you are granted, each with its kind, its address, its state (syncing, " + + "current, waits-for-sign-in or failing, and why), when its server last answered, when it was last brought up to " + + "date, and its folders with how many messages each holds. An account you are not granted is not listed, and " + + "nothing of it is said. How the module as a whole is, is mail_health's to say. Read-only.", + Input: schema(nil, map[string]any{}), + Run: b.accountsTool}, + {Name: SearchTool, + Description: "Search the mail of the accounts you are granted: every word of the query must be in the subject, the " + + "text, a sender or a recipient. Answers the newest first, at most 50: account, folder, uid, message id, date, " + + "sender, recipients, subject, size, flags and attachment references, never a body. Refused for an account you " + + "are not granted, and when you are granted none. Read-only.", + Input: schema([]string{"query"}, map[string]any{ + "query": text("the words to find, at most 200 characters"), + "account": text("one account by its name in mail_accounts; every account you are granted when left out"), + "folder": text("one folder, such as INBOX; every folder when left out"), + "limit": integer("how many to answer, at most 50 (20 when left out)"), + }), + Run: b.searchTool}, + {Name: ReadTool, + Description: "Read one message of an account you are granted, as an event or mail_search names it (account, folder, " + + "uid): its metadata and its text (the plain part, or the HTML part as text, at most 64 KiB here). With " + + "attachment (its key, the SHA-256 the message's references name), that one attachment's bytes, base64, at most " + + "8 MiB. Refused for an account you are not granted. Never marks a message seen. Read-only.", + Input: schema([]string{"account", "folder", "uid"}, map[string]any{ + "account": text("the account, by its name in mail_accounts"), + "folder": text("the folder, such as INBOX"), + "uid": integer("the message's uid in that folder"), + "attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"), + }), + Run: b.readTool}, + {Name: HealthTool, + Description: "How mesh-mail is, as its watch last saw it: healthy when every account's server answered within five " + + "minutes; unhealthy, naming the account and why, when a connection is down or its IDLE on the inbox went silent; " + + "waits, naming the account's password, while the operator has not given it at the desk.", + Input: schema(nil, map[string]any{}), + Run: func(map[string]any) (any, error) { return ReadStatus(b.Env.Status, b.now()), nil }}, + {Name: SendTool, + Description: "Send mail from an account you are granted (the same check as mail_read, novox/hq ADR 0361 §2). It " + + "sends nothing itself: it raises an ask on the operator channel — the account, the recipients, the subject and " + + "the first lines of the body — and answers at once with the ask's id. The mail reaches the account's outgoing " + + "server only once the operator's Approve comes back on a channel that proves who answered, and once; on " + + "Decline, or no answer before it expires, nothing is sent. Refused for an account you are not granted, or one " + + "with no outgoing server. mail_send_status, with this id, says where it stands.", + Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{ + "account": text("the account to send from, by its name in mail_accounts"), + "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the recipients' mail addresses, at most 20"}, + "subject": text("the message's subject, at most 200 characters"), + "body": text("the message's plain-text body"), + }), + Run: b.sendTool}, + {Name: SendStatusTool, + Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " + + "ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " + + "to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).", + Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}), + Run: b.sendStatusTool}, + {Name: SeatAccountsTool, + Description: "The mail-store seat's accounts verb (novox/hq ADR 0378 §2): the same as mail_accounts.", + Input: schema(nil, map[string]any{}), + Run: b.accountsTool}, + {Name: SeatSearchTool, + Description: "The mail-store seat's search verb (novox/hq ADR 0378 §2): the same as mail_search.", + Input: schema([]string{"query"}, map[string]any{ + "query": text("the words to find, at most 200 characters"), + "account": text("one account by its name in mail_accounts; every account you are granted when left out"), + "folder": text("one folder, such as INBOX; every folder when left out"), + "limit": integer("how many to answer, at most 50 (20 when left out)"), + }), + Run: b.searchTool}, + {Name: SeatReadTool, + Description: "The mail-store seat's read verb (novox/hq ADR 0378 §2): the same as mail_read.", + Input: schema([]string{"account", "folder", "uid"}, map[string]any{ + "account": text("the account, by its name in mail_accounts"), + "folder": text("the folder, such as INBOX"), + "uid": integer("the message's uid in that folder"), + "attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"), + }), + Run: b.readTool}, + {Name: SeatSendTool, + Description: "The mail-store seat's send verb (novox/hq ADR 0378 §2): the same as mail_send.", + Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{ + "account": text("the account to send from, by its name in mail_accounts"), + "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the recipients' mail addresses, at most 20"}, + "subject": text("the message's subject, at most 200 characters"), + "body": text("the message's plain-text body"), + }), + Run: b.sendTool}, + } +} + +func (b *Bundle) caller(args map[string]any) string { + if b.Caller != nil { + return b.Caller(args) + } + return stdio.CallerOf(args) +} + +func (b *Bundle) accountsTool(args map[string]any) (any, error) { + if err := only(args); err != nil { + return nil, err + } + accounts, grants, err := b.settings() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + st, err := b.Index(ctx) + if err != nil { + return nil, fmt.Errorf("the index cannot be reached: %v", err) + } + states, err := st.States(ctx) + if err != nil { + return nil, err + } + caller := b.caller(args) + out := []map[string]any{} + for _, a := range accounts { + // Only the accounts the caller is granted: of any other it learns nothing, not even that it exists. + if !grants.Allows(caller, a.Name) { + continue + } + entry := map[string]any{"account": a.Name, "kind": a.Kind, "address": a.Address} + if s, ok := states[a.Name]; ok { + entry["state"], entry["why"] = s.State, s.Why + if !s.Heard.IsZero() { + entry["heard"] = s.Heard.UTC().Format(time.RFC3339) + } + if !s.Synced.IsZero() { + entry["synced"] = s.Synced.UTC().Format(time.RFC3339) + } + } else { + entry["state"], entry["why"] = "unknown", "its keeper has said nothing of it yet" + } + folders, err := st.Folders(ctx, a.Name) + if err != nil { + return nil, err + } + entry["folders"] = folders + out = append(out, entry) + } + answer := map[string]any{"accounts": out, "caller": caller} + if grants.unread != nil { + answer["why"] = "the setting grants cannot be read, so it grants nobody: " + grants.unread.Error() + } + return answer, nil +} + +func (b *Bundle) searchTool(args map[string]any) (any, error) { + if err := only(args, "query", "account", "folder", "limit"); err != nil { + return nil, err + } + query, _ := args["query"].(string) + if strings.TrimSpace(query) == "" || len(query) > MostQuery { + return nil, fmt.Errorf("%s needs a query of 1 to %d characters", SearchTool, MostQuery) + } + account, _ := args["account"].(string) + folder, _ := args["folder"].(string) + limit, err := number(args, "limit", 20) + if err != nil { + return nil, err + } + if limit < 1 || limit > MostResults { + return nil, fmt.Errorf("limit is 1 to %d", MostResults) + } + accounts, grants, err := b.settings() + if err != nil { + return nil, err + } + caller := b.caller(args) + granted := grants.Granted(caller, accounts) + if account != "" { + if !grants.Allows(caller, account) { + return nil, grants.refuse(caller, account) + } + granted = []string{account} + } + if len(granted) == 0 { + if grants.unread != nil { + return nil, grants.refuse(caller, "") + } + return nil, fmt.Errorf("refused: %s is granted no mail account; the operator grants one through the setting grants", caller) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + st, err := b.Index(ctx) + if err != nil { + return nil, fmt.Errorf("the index cannot be reached: %v", err) + } + found, err := st.Search(ctx, Query{Text: query, Accounts: granted, Folder: folder, Limit: limit}) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(found)) + for _, m := range found { + out = append(out, metadata(m)) + } + return map[string]any{"searched": granted, "found": out}, nil +} + +func (b *Bundle) readTool(args map[string]any) (any, error) { + if err := only(args, "account", "folder", "uid", "attachment"); err != nil { + return nil, err + } + account, _ := args["account"].(string) + folder, _ := args["folder"].(string) + attachment, _ := args["attachment"].(string) + uid, err := number(args, "uid", 0) + if err != nil { + return nil, err + } + if account == "" || folder == "" || uid <= 0 { + return nil, fmt.Errorf("%s needs an account, a folder and a uid", ReadTool) + } + _, grants, err := b.settings() + if err != nil { + return nil, err + } + caller := b.caller(args) + if !grants.Allows(caller, account) { + return nil, grants.refuse(caller, account) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + st, err := b.Index(ctx) + if err != nil { + return nil, fmt.Errorf("the index cannot be reached: %v", err) + } + m, ok, err := st.Get(ctx, account, folder, uint32(uid)) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("the account %s holds no message %d in %s", account, uid, folder) + } + if attachment != "" { + var ref *AttachmentRef + for i := range m.Attachments { + if m.Attachments[i].Key == attachment { + ref = &m.Attachments[i] + } + } + if ref == nil { + return nil, fmt.Errorf("the message names no attachment %s", attachment) + } + if ref.Size > MostAttachment { + return nil, fmt.Errorf("the attachment %s is %d bytes, more than the %d this tool answers", ref.Name, ref.Size, MostAttachment) + } + o, err := b.Objects() + if err != nil { + return nil, err + } + data, err := o.Get(ctx, ref.Key) + if err != nil { + return nil, err + } + return map[string]any{"attachment": ref, "base64": base64.StdEncoding.EncodeToString(data)}, nil + } + out := metadata(m) + body := m.Body + if len(body) > MostBodyAnswered { + body = strings.ToValidUTF8(body[:MostBodyAnswered], "") + "\n[cut at 64 KiB]" + } + out["text"] = body + return out, nil +} + +func refused(caller, account string) error { + return fmt.Errorf("refused: %s is not granted the mail account %s; the operator grants it through the setting grants", caller, account) +} + +// ---- mail_send, mail_send_status --------------------------------------------------------------------------------- + +// askBus is the operator channel this bundle reaches: the bus's own, unless a test gave one. +func (b *Bundle) askBus() AskBus { + if b.Asks != nil { + return b.Asks + } + return sdkAskBus{} +} + +// sends is the `sends` state this bundle keeps: the bus's own, unless a test gave one. +func (b *Bundle) sends() SendStore { + if b.Sends != nil { + return b.Sends + } + return keptSends{stdio.State(SendsState)} +} + +// lookupAccount resolves an account by name as the settings currently hold it — read fresh at decide time, never +// the account as it stood when it was asked. +func (b *Bundle) lookupAccount(name string) (Account, bool) { + accounts, err := ReadAccounts(b.Env.Accounts) + if err != nil { + return Account{}, false + } + return accountByName(accounts, name) +} + +func accountByName(accounts []Account, name string) (Account, bool) { + for _, a := range accounts { + if a.Name == name { + return a, true + } + } + return Account{}, false +} + +// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and +// the `sends` state never hold a password. +func (b *Bundle) sendActor() SendActor { + dial := b.Dial + if dial == nil { + dial = DialSMTP + } + return func(a Account, to []string, subject, body string, now time.Time) error { + pw, given, err := ReadSecret(b.Env.Secrets, a) + if err != nil { + return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err) + } + if !given { + return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name) + } + return SendMail(dial, a, pw, to, subject, body, now) + } +} + +func stringList(v any) []string { + list, _ := v.([]any) + out := make([]string, 0, len(list)) + for _, item := range list { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func (b *Bundle) sendTool(args map[string]any) (any, error) { + if err := only(args, "account", "to", "subject", "body"); err != nil { + return nil, err + } + account, _ := args["account"].(string) + subject, _ := args["subject"].(string) + body, _ := args["body"].(string) + to := stringList(args["to"]) + if account == "" { + return nil, fmt.Errorf("%s needs account, by its name in mail_accounts", SendTool) + } + accounts, grants, err := b.settings() + if err != nil { + return nil, err + } + caller := b.caller(args) + // The same grants check as mail_read (novox/hq ADR 0359 §4, ADR 0361 §2): an ungranted caller is refused, and + // nothing is asked. + if !grants.Allows(caller, account) { + return nil, grants.refuse(caller, account) + } + acc, found := accountByName(accounts, account) + if !found { + return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account) + } + return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts}, + b.sends(), b.askBus(), b.now()) +} + +func (b *Bundle) sendStatusTool(args map[string]any) (any, error) { + if err := only(args, "id"); err != nil { + return nil, err + } + id, _ := args["id"].(string) + if id == "" { + return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool) + } + return ReadSendStatus(b.sends(), b.askBus(), b.lookupAccount, b.sendActor(), id, b.now()) +} + +// ---- arguments ---------------------------------------------------------------------------------------------------- + +// only refuses an argument the tool does not take: never ignored, and none can say who calls. +func only(args map[string]any, takes ...string) error { + for k := range args { + if !contains(takes, k) { + return fmt.Errorf("the argument %q is not taken", k) + } + } + return nil +} + +func number(args map[string]any, key string, otherwise int) (int, error) { + v, ok := args[key] + if !ok || v == nil { + return otherwise, nil + } + f, ok := v.(float64) + if !ok || f != float64(int(f)) { + return 0, errors.New(key + " is a whole number") + } + return int(f), nil +} + +func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} } +func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} } + +func schema(required []string, props map[string]any) map[string]any { + s := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(required) > 0 { + s["required"] = required + } + return s +} diff --git a/modules/mesh-mail/cmd/mesh-mail/tools_test.go b/modules/mesh-mail/cmd/mesh-mail/tools_test.go new file mode 100644 index 00000000..fbc9c6c7 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/tools_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// runtime plays the node's runtime against the bundle over its MCP channel, as the bundle is launched: it calls tools +// naming who called in `_meta`, as the runtime does from the subject a call arrived on, and takes what the bundle +// publishes. +type runtime struct { + t *testing.T + in *io.PipeWriter + mu sync.Mutex + next int + answers map[string]chan map[string]any + published []map[string]any + refuse bool +} + +func startBundle(t *testing.T, tools []stdio.Tool) *runtime { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + r := &runtime{t: t, in: inW, answers: map[string]chan map[string]any{}} + go func() { _ = stdio.ServeOn(Module, tools, inR, outW) }() + go func() { + sc := bufio.NewScanner(outR) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var m map[string]any + if json.Unmarshal(sc.Bytes(), &m) != nil { + continue + } + id, _ := json.Marshal(m["id"]) + if m["method"] == "mesh/publish" { + r.mu.Lock() + refuse := r.refuse + if !refuse { + r.published = append(r.published, m["params"].(map[string]any)) + } + r.mu.Unlock() + reply := map[string]any{"id": m["id"], "result": map[string]any{}} + if refuse { + reply = map[string]any{"id": m["id"], "error": map[string]any{"code": -32000, "message": "the bus is away"}} + } + r.send(reply) + continue + } + r.mu.Lock() + ch := r.answers[string(id)] + r.mu.Unlock() + if ch != nil { + ch <- m + } + } + }() + t.Cleanup(func() { _ = inW.Close(); _ = outW.Close() }) + // Answered once the bundle serves: from then on, what it emits goes through this runtime and no other. + r.mu.Lock() + ready := make(chan map[string]any, 1) + r.answers[`"init"`] = ready + r.mu.Unlock() + r.send(map[string]any{"jsonrpc": "2.0", "id": "init", "method": "initialize", "params": map[string]any{}}) + select { + case <-ready: + case <-time.After(10 * time.Second): + t.Fatal("the bundle did not start") + } + return r +} + +func (r *runtime) send(m map[string]any) { + raw, _ := json.Marshal(m) + r.mu.Lock() + defer r.mu.Unlock() + _, _ = r.in.Write(append(raw, '\n')) +} + +// call calls a tool as the caller, answering its text and whether it was an error. +func (r *runtime) call(tool, caller string, args map[string]any) (string, bool) { + r.mu.Lock() + r.next++ + id := r.next + ch := make(chan map[string]any, 1) + r.answers[fmt.Sprint(id)] = ch + r.mu.Unlock() + r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": map[string]any{"name": tool, "arguments": args, "_meta": map[string]any{stdio.MetaCaller: caller}}}) + select { + case m := <-ch: + res := m["result"].(map[string]any) + text := res["content"].([]any)[0].(map[string]any)["text"].(string) + isErr, _ := res["isError"].(bool) + return text, isErr + case <-time.After(10 * time.Second): + r.t.Fatalf("%s did not answer", tool) + } + return "", true +} + +// aBundle is the bundle over an index holding one message with an attachment, granted to the module triage alone. +func aBundle(t *testing.T) (*Bundle, *memStore, *memObjects) { + t.Helper() + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir, + Status: filepath.Join(dir, "health.json")} + writeFile(t, env.Accounts, stageOne) + writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`) + st, objects := newMemStore(), newMemObjects() + ctx := context.Background() + ref, err := Keep(ctx, objects, []byte(attachmentData), "application/pdf", "invoice.pdf") + if err != nil { + t.Fatal(err) + } + m := Message{Account: "mesh", Folder: "INBOX", UID: 7, UIDValidity: 1, MessageID: "att@example.org", Subject: "the invoice", + Date: judgedAt, From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}}, + Flags: []string{}, Attachments: []AttachmentRef{ref}, Body: "Please find the invoice attached."} + if err := st.Add(ctx, m, addedEvent(m)); err != nil { + t.Fatal(err) + } + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt, Said: judgedAt}) + b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil }, + Objects: func() (Objects, error) { return objects, nil }, Now: func() time.Time { return judgedAt }} + return b, st, objects +} + +// The body tools answer a granted caller and refuse every other (ADR 0359 §4, "How it is checked"): the caller is the +// runtime's word, never an argument. +func TestTheBodyToolsAnswerAGrantedCallerAndRefuseAnyOther(t *testing.T) { + b, _, _ := aBundle(t) + rt := startBundle(t, b.Tools()) + read := map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7} + + text, isErr := rt.call(ReadTool, "novox.triage", read) + if isErr || !strings.Contains(text, "Please find the invoice attached.") || !strings.Contains(text, `"subject":"the invoice"`) { + t.Fatalf("granted: %v %s", isErr, text) + } + for _, caller := range []string{"novox.other", "person.someone", Unproven} { + text, isErr := rt.call(ReadTool, caller, read) + if !isErr || !strings.Contains(text, "refused") || strings.Contains(text, "Please find") { + t.Fatalf("%s: %v %s", caller, isErr, text) + } + text, isErr = rt.call(SearchTool, caller, map[string]any{"query": "invoice"}) + if !isErr || !strings.Contains(text, "refused") { + t.Fatalf("%s searched: %v %s", caller, isErr, text) + } + } + // An argument cannot name the caller. + if text, isErr := rt.call(ReadTool, "novox.other", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "caller": "novox.triage"}); !isErr || strings.Contains(text, "Please find") { + t.Fatalf("an argument named the caller: %s", text) + } + + text, isErr = rt.call(SearchTool, "shanks.triage", map[string]any{"query": "invoice"}) + if isErr || !strings.Contains(text, `"message-id":"att@example.org"`) || strings.Contains(text, "Please find") { + t.Fatalf("search: %v %s", isErr, text) + } + + sum := "" + if m, _ := b.Index(context.Background()); m != nil { + got, _, _ := m.Get(context.Background(), "mesh", "INBOX", 7) + sum = got.Attachments[0].Key + } + text, isErr = rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "attachment": sum}) + var att struct { + Base64 string `json:"base64"` + } + if isErr || json.Unmarshal([]byte(text), &att) != nil { + t.Fatalf("attachment: %v %s", isErr, text) + } + if data, _ := base64.StdEncoding.DecodeString(att.Base64); string(data) != attachmentData { + t.Fatalf("the attachment read is %q", data) + } +} + +// mail_accounts says each account's state to anyone, and its address only to a caller granted it. +func TestTheAccountsToolListsOnlyTheAccountsTheCallerIsGranted(t *testing.T) { + b, _, _ := aBundle(t) + rt := startBundle(t, b.Tools()) + _ = b.Env // the index holds a folder of the account, so an ungranted answer has something to leak + st, _ := b.Index(context.Background()) + _ = st.SetFolder(context.Background(), Folder{Account: "mesh", Name: "Secret-Project", Messages: 41, Synced: judgedAt}) + _ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountFailing, Why: "the server said NO [AUTHENTICATIONFAILED]", Said: judgedAt}) + text, isErr := rt.call(AccountsTool, "novox.other", map[string]any{}) + if isErr || !strings.Contains(text, `"accounts":[]`) { + t.Fatalf("ungranted: %s", text) + } + for _, leak := range []string{"mesh\"", "jschoubben@", "Secret-Project", "41", "AUTHENTICATIONFAILED", "failing"} { + if strings.Contains(text, leak) { + t.Fatalf("an ungranted caller learns %q: %s", leak, text) + } + } + text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{}) + if !strings.Contains(text, `"address":"jschoubben@novox.be"`) || !strings.Contains(text, "Secret-Project") || !strings.Contains(text, "AUTHENTICATIONFAILED") { + t.Fatalf("granted: %s", text) + } +} + +// The events the keeper wrote are said on the bus as the module, through the runtime, with their metadata and never a +// body; each is removed once the bus took it, and kept when it did not. +func TestTheEventsAreSaidOnTheBusThroughTheRuntime(t *testing.T) { + b, st, _ := aBundle(t) + rt := startBundle(t, b.Tools()) + p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: t.Logf} + ctx := context.Background() + + rt.mu.Lock() + rt.refuse = true + rt.mu.Unlock() + if n, err := p.Once(ctx); n != 0 || err == nil { + t.Fatalf("said %d with the bus away (%v)", n, err) + } + if left, _ := st.Pending(ctx, 10); len(left) != 1 { + t.Fatalf("an event the bus did not take was dropped: %d left", len(left)) + } + + rt.mu.Lock() + rt.refuse = false + rt.mu.Unlock() + if n, err := p.Once(ctx); n != 1 || err != nil { + t.Fatalf("said %d (%v)", n, err) + } + if left, _ := st.Pending(ctx, 10); len(left) != 0 { + t.Fatalf("%d event(s) left after the bus took them", len(left)) + } + rt.mu.Lock() + defer rt.mu.Unlock() + if len(rt.published) != 1 { + t.Fatalf("published %d", len(rt.published)) + } + pub := rt.published[0] + raw, _ := json.Marshal(pub) + if pub["key"] != "mail.mesh.message.added" || !strings.Contains(string(raw), `"subject":"the invoice"`) || + strings.Contains(string(raw), "Please find") || strings.Contains(string(raw), "dGhlIGludm9pY2Ug") { + t.Fatalf("published %s", raw) + } +} + +// A grants setting that cannot be read grants nobody, and every refusal says why (issue 733). +func TestARefusalSaysWhyTheGrantsCouldNotBeRead(t *testing.T) { + b, _, _ := aBundle(t) + writeFile(t, b.Env.Grants, `{"grants": {"mesh": "triage"}}`) + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7}) + if !isErr || !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, "not the module's") || strings.Contains(text, "Please find") { + t.Fatalf("read: %v %s", isErr, text) + } + text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"query": "invoice"}) + if !isErr || !strings.Contains(text, "the setting grants cannot be read") { + t.Fatalf("search: %v %s", isErr, text) + } + text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{}) + if !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, `"accounts":[]`) { + t.Fatalf("accounts: %s", text) + } +} diff --git a/modules/mesh-mail/files/accounts.json b/modules/mesh-mail/files/accounts.json new file mode 100644 index 00000000..e182cc3d --- /dev/null +++ b/modules/mesh-mail/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/modules/mesh-mail/files/database-url b/modules/mesh-mail/files/database-url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/modules/mesh-mail/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-mail/files/grants.json b/modules/mesh-mail/files/grants.json new file mode 100644 index 00000000..458eabba --- /dev/null +++ b/modules/mesh-mail/files/grants.json @@ -0,0 +1,3 @@ +{ + "grants": {} +} diff --git a/modules/mesh-mail/files/store.json b/modules/mesh-mail/files/store.json new file mode 100644 index 00000000..8c2075da --- /dev/null +++ b/modules/mesh-mail/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/modules/mesh-mail/go.mod b/modules/mesh-mail/go.mod new file mode 100644 index 00000000..16e2aaf6 --- /dev/null +++ b/modules/mesh-mail/go.mod @@ -0,0 +1,19 @@ +module mesh-mail + +go 1.25.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 + github.com/jackc/pgx/v5 v5.11.0 +) + +require ( + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/modules/mesh-mail/go.sum b/modules/mesh-mail/go.sum new file mode 100644 index 00000000..da60653c --- /dev/null +++ b/modules/mesh-mail/go.sum @@ -0,0 +1,65 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json new file mode 100644 index 00000000..ae445990 --- /dev/null +++ b/modules/mesh-mail/module.json @@ -0,0 +1,246 @@ +{ + "module": "mesh-mail", + "version": "1", + "slug": "mail", + "seats": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send"] + }, + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + }, + { + "name": "mail-notifier", + "scope": "node", + "needs": ["node-notifier"], + "serves": ["status"] + } + ], + "claims": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send"] + } + ], + "requires": [ + "postgres-database", + "s3-bucket" + ], + "contributes": { + "postgres-database": { + "name": "mail" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "own-secrets": { + "mail-credential-*": { + "path": "${dir:state}/mail-credential-*.secret", + "issued-by": "outside", + "prompt": "the password of the mail account {member}" + } + }, + "config": { + "accounts": { + "type": "object", + "description": "The mail accounts, each by a lower-case name that names it on the bus and its secret: its kind (imap), address, incoming and outgoing servers (host, port, security tls or starttls), and optionally the folders watched and the user it signs in as; no password, which is the own secret mail-credential- (novox/hq ADR 0359 §5).", + "trusted": true, + "default": {} + }, + "grants": { + "type": "object", + "description": "Per account, who may read its mail and who may send from it: a module's name (on every machine), ., or person.. Nobody by default; it restricts the stream MAIL and the tools mail_search, mail_read and mail_send alike (novox/hq ADR 0359 §4).", + "trusted": true, + "default": {} + } + }, + "tools": [ + "mail_accounts", + "mail_search", + "mail_read", + "mail_health", + "mail_send", + "mail_send_status", + "mail-store.accounts", + "mail-store.search", + "mail-store.read", + "mail-store.send" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "sends" + ], + "streams": [ + { + "name": "mail", + "events": [ + "mail.*.message.added", + "mail.*.message.changed", + "mail.*.message.removed" + ], + "max-age-days": 7, + "grant": { + "config": "grants", + "events": "mail.{key}.**" + } + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database-url" + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "accounts", + "trusted": true, + "type": "file", + "path": "${dir:state}/accounts.json", + "mode": "0600", + "merge": "json", + "template": "files/accounts.json", + "takes-config": [ + "accounts" + ] + }, + { + "id": "grants", + "trusted": true, + "type": "file", + "path": "${dir:state}/grants.json", + "mode": "0600", + "merge": "json", + "template": "files/grants.json", + "takes-config": [ + "grants" + ] + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-mail-keep", + "artifact": "tools", + "run": [ + "./mesh-mail", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json" + }, + "restart-on": [ + "database-url", + "store", + "accounts" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mail-watch", + "artifact": "tools", + "run": [ + "./mesh-mail", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url", + "accounts" + ], + "health": { + "kind": "tool", + "tool": "mail_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail", + "binary": "mesh-mail", + "loads": [ + "mesh-mail" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_GRANTS": "${dir:state}/grants.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/mesh-provisioner-client/README.md b/modules/mesh-provisioner-client/README.md new file mode 100644 index 00000000..62c1ec4c --- /dev/null +++ b/modules/mesh-provisioner-client/README.md @@ -0,0 +1,88 @@ +# mesh-provisioner-client + +Holds the `node-provisioner` seat on every node: the provisioner's client (novox/hq ADR 0349 clauses 3 to 5, design 57 +P5). Go, one binary (`cmd/mesh-provisioner-client`), run as a service of its own under the account +`mesh-provisioner-client`, outside the tool runner, on a bus account of its own, `provisioner.`, delivered as +its own secret `provisioner-broker` (mesh-controller `internal/broker/provisioner.go`). The bus grants that account +its own machine's keys and, to read alone, the whole assignments mirror, where a provider's consumers on any +machine are bound to it (ADR 0373); nothing else. + +## What it does + +For each module the controller handed to the provisioner on its node — an assignment in +`mesh-controller_assignments` (`.`) whose value carries a `provisioning` plan: the templates archive +by reference and digest, the files it fills (path, template, `owner-at-runtime`, how its program takes a change) +and the placement values its templates name — the client: + +- fetches the templates archive and uses it only when it hashes to the plan's digest, keeping it by digest; +- records each placeholder as a need in `mesh-provisioner_needs`, `...` (a character a + key cannot hold written `=`); +- watches each owner's keys for its node: placement in the plan, config in `mesh-config_resolved` + (`..`), both named by `mesh-provisioner_registry`; +- asks the vault (`mesh-vault.get`, named by the registry) for each of the module's own secrets its plan names + (`provisioning.secrets`), in its own name on the subject naming its account, with the public half of a key it + made at start and keeps in memory alone; the vault answers the secret sealed to that key (ADR 0206's box), so only + the box crosses the bus. A secret is asked once per run, and again when the vault says `secret-changed` for it, + so a rotation reaches its file without a new declaration. A secret with a `path` is written to that file alone, + as an `owned` file. A secret the vault does not hold yet, or a vault that does not answer, is waited for; a + refusal fails the module with the vault's reason and is asked again after ten minutes. A value is never written + to a bucket, a report or a log; +- asks the vault the same way for each credential of what the module requires that its plan names + (a `provisioning.provisions` entry with a `name`, issue 757; every entry names the provider serving that + provision, issue 760, and its offer's credential form, issue 768, which the vault acts on): the value a provider's software issued for this module on this machine, + `provision////` (the forge's read token for claude-code, issue 737), filled + wherever `${secret:}` names it and written to its `path` alone, and asked again on its `secret-changed`; +- fills each item the module contributes to a collection (`provisioning.items`: the collection and its template) + from the same owners as a file, and records it in `mesh-provisioner_collect`, `..` + (a character a key's token cannot hold written `=`: `shell:zsh:normal` is `shell=3Azsh=3Anormal`). An item + naming a secret fails: an item is kept in a bucket, and no secret is; +- fills a collector's `${collected:}` from every item of that collection recorded for its node, in the + contributors' order, each introduced by a line naming its contributor in the collection's comment + (`provisioning.collections`: the collections the module collects, each with its comment). The three old + namespaces read as collections of the same name: `${shell:zsh:normal}` is the collection `shell:zsh:normal`, + `${environment:posix}` is `environment:posix`, `${contribution:node-hotkeys:trigger}` is + `contribution:node-hotkeys:trigger`. A collector naming a collection its plan does not fails. A collection with no + item is filled empty, and filled again whenever an item of it is recorded or goes; +- fills a module's files only once every value is there; otherwise it writes nothing and reports `waiting` with what + it waits for; +- has the node-engine write each changed file through its local write socket (`/run/nox-mesh-host/fill.sock`, + mesh-host `internal/filling`), which writes it as the declaration says (an `owned` file whole, a `seed` only when + absent, a `managed` file's own keys or block) and restarts or reloads its program as the declaration says; +- writes every file again every minute, so the node-engine puts back an `owned` file changed on disk; +- reports `prov.applied..` and keeps the same report in `mesh-provisioner_applied` (`.`): + `applied` with each file's action and digest, `waiting`, `failed` with the reason, or `removed`; +- when a module is taken off the node, removes its needs, its items and its report (the node-engine removes the files its + declaration no longer names); and when the bus comes back, or the client starts, fills from the current values; +- fills a provider's `${consumers:}` (novox/hq ADR 0373, issue 679) with one entry per consumer bound to + it for that provision, in the order of the assignments' keys and, within a plan, of its `provisions` entries: a + JSON list, each entry `{consumer, module, local, credential, value}`, the credential its own, + `pair////[/]`, asked of the vault in the client's own name as a secret + is. Who binds a provider is read from every machine's plans, so the client watches the whole assignments mirror + (it carries no secret's value) and reads only its own machine's plans as its own. A consumer whose credential the + vault does not hold yet, or will not hand, is left out and said in the log, so the others are served; the + provider's helper waits for it (mesh-sdk `go/provider` ConsumersFile). It is filled again when a consumer binds or + unbinds and when the vault says one of the credentials changed. The catalogue holds the file to mode 0600 and + owned, and refuses the placeholder in a module not providing that provision (mesh-controller internal/catalogue + consumers.go); an item naming it fails; +- fills a consumer's `${bound::}` from its provider's entry (mesh-sdk `go/provider`, issue 678): the + bucket `_bound` of the provider its plan names, key `..[.]`, as + the registry says, watched for its own machine's keys once a plan names it; `as`, `user` or one of the served + values. An entry not written yet, or a bucket not there yet, is waited for; one the provider refused fails the + module with the provider's reason. + +It fills `config`, `secret` (design 57 V4, issue 647), the collections (`collected`, and `shell`, `environment` +and `contribution` read as collections: design 57 L1, issue 684), `consumers` and `bound` (issue 679) and the +placement kinds (`dir`, `access`, `port`, `seat`, `machine`). Any other kind fails with the reason. + +## A module's code asks at runtime + +A module whose code runs in its machine's tool runner reads its config values through the SDK (ADR 0349 clause 14 +as ADR 0370 changes it, issue 685). The tool runner asks this client on `prov.ask.`, which only the same +machine's tool runner may publish, with `{module, kind, key}`; the module is the one whose bundle asks, named by the +tool runner and never by the module's code. The client answers `{value}`, the value as `mesh-config_resolved` holds +it, only for a module assigned to its machine and a key mesh-config resolved for it (what its manifest declares), or +`{error}` with the reason. It never answers a secret: the vault hands a secret only to a caller the bus proves, so a +module in the tool runner reads its secrets from its files. A binding's non-secret values (`bound`) are filled +into files (issue 679) and not yet answered on an ask. It says on `prov.changed..` which value moved (`{kind, key}`, every +key of the kind when `key` is absent, never the value), when the client starts and on every change, so the tool +runner asks again for the watches it holds. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks.go new file mode 100644 index 00000000..d4753a25 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/nats-io/nats.go" +) + +// A module's code asks at runtime (novox/hq ADR 0349 clause 14 as ADR 0370 changes it, design 57 S1, issue 685). +// +// While a module's code runs in its machine's tool runner, its asks reach the bus under the tool runner's account, +// and the bus proves only that account. So the client is asked on AskSubject, which the controller grants its own +// machine's tool runner alone to publish (ADR 0370 §3), and the module is the one the tool runner names on the ask, +// taken from the bundle that asks (§4). The client answers that module's config values, for what its manifest +// declares — mesh-config resolves a module's declared keys and no other — and only when the composition places the +// module on this machine, where the tool runner carries every module assigned to it. It never answers a secret on +// the tool runner's word (§2): the vault hands a secret only to a caller the bus proves (ADR 0349 clause 11), and a +// module in the tool runner reads its secrets from its files. A binding's non-secret values are its provider's +// (ADR 0349 clause 12); this client fills none yet, so it answers none yet either, and says so. +// +// A watch is the tool runner's: the client says on ChangedSubject which value of a module moved, never the value, +// and the tool runner asks again for the watches it holds. + +// AskSubject is where this machine's tool runner asks (mesh-controller internal/broker ProvisionerAskSubject). +func AskSubject(node string) string { return "prov.ask." + node } + +// ChangedSubject is where the client says a value of one module moved (mesh-controller internal/broker +// ProvisionerChangedSubject). +func ChangedSubject(node, module string) string { return "prov.changed." + node + "." + module } + +// The kinds of value an ask names. +const ( + AskConfig = "config" + AskBound = "bound" + AskSecret = "secret" +) + +// Ask is what the tool runner asks: the module it names, from the bundle that asks; the kind; and the key, a +// config key, or `:` of a binding. +type Ask struct { + Module string `json:"module"` + Kind string `json:"kind"` + Key string `json:"key"` +} + +// Answered is the client's answer: the value as mesh-config resolved it (JSON), or why it is refused. +type Answered struct { + Value json.RawMessage `json:"value,omitempty"` + Error string `json:"error,omitempty"` +} + +// Changed is what the client says moved of one module: a kind, and the key, or every key of the kind when empty. +type Changed struct { + Kind string `json:"kind"` + Key string `json:"key,omitempty"` +} + +// answer is the answer to one ask from the state the client holds. +func (c *Client) answer(s *state, body []byte) Answered { + var a Ask + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil || a.Module == "" || a.Kind == "" || a.Key == "" { + return Answered{Error: "an ask names its module, its kind and its key: {module, kind, key}"} + } + switch a.Kind { + case AskSecret: + return Answered{Error: fmt.Sprintf("%s's secret %s is not answered on the tool runner's word: the vault hands "+ + "a secret only to a caller the bus proves (novox/hq ADR 0370 §2, ADR 0349 §11); while the module's code "+ + "runs in the tool runner, its secrets reach it through its files", a.Module, a.Key)} + case AskConfig, AskBound: + default: + return Answered{Error: fmt.Sprintf("%q is not a kind the client answers: config or bound", a.Kind)} + } + if !s.assigned[a.Module] { + return Answered{Error: fmt.Sprintf("%s is not placed on %s, so this machine's tool runner carries no code of "+ + "it to answer (novox/hq ADR 0370 §3)", a.Module, c.Node)} + } + if a.Kind == AskBound { + return Answered{Error: fmt.Sprintf("%s's bound value %s is not answered yet: a binding's values come from its "+ + "provider (novox/hq ADR 0349 §12), which this client fills into the module's files (issue 679) and does not "+ + "answer on an ask yet", a.Module, a.Key)} + } + v, ok := s.config[a.Module][a.Key] + if !ok { + return Answered{Error: fmt.Sprintf("%s declares no config value %s, or mesh-config holds none for it on %s", + a.Module, a.Key, c.Node)} + } + return Answered{Value: v} +} + +// respond answers one ask on its reply. +func (c *Client) respond(s *state, m *nats.Msg) { + if m.Reply == "" { + return + } + a := c.answer(s, m.Data) + body, err := json.Marshal(a) + if err != nil { + return + } + if err := m.Respond(body); err != nil { + logf("[provisioner-client] an ask could not be answered: %v", err) + } +} + +// tell says a value of a module moved, so its code's watches are asked again. +func (c *Client) tell(module string, ch Changed) { + if module == "" || strings.ContainsAny(module, "*> ") { + return + } + body, _ := json.Marshal(ch) + if err := c.Conn.Publish(ChangedSubject(c.Node, module), body); err != nil { + logf("[provisioner-client] what moved of %s could not be said: %v", module, err) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go new file mode 100644 index 00000000..f9908a01 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" +) + +// A module's code asks at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1, issue 685), against a real +// bus whose accounts are the client's and its machine's tool runner's as mesh-controller composes them: the tool +// runner asks for a carried module's config value and is answered; a change is said, so a watch asks again; a key +// the module does not declare, a module not placed on the machine, a secret and a malformed ask are refused, each +// with its reason. + +// toolRunner is g14's tool runner on the bus, asking as its account may. +type toolRunner struct { + t *testing.T + nc *nats.Conn +} + +func aToolRunner(t *testing.T, b *aBus) *toolRunner { + t.Helper() + nc, err := nats.Connect(b.url, nats.UserInfo(node+".node-tools", "x"), + nats.CustomInboxPrefix("_INBOX."+node+".node-tools"), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { + t.Errorf("the bus refused the tool runner: %v", err) + })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + return &toolRunner{t: t, nc: nc} +} + +func (r *toolRunner) ask(body string) Answered { + r.t.Helper() + var a Answered + deadline := time.Now().Add(10 * time.Second) + for { + msg, err := r.nc.Request(AskSubject(node), []byte(body), 2*time.Second) + if err == nil { + if err := json.Unmarshal(msg.Data, &a); err != nil { + r.t.Fatalf("the client answered %s", msg.Data) + } + return a + } + if time.Now().After(deadline) { + r.t.Fatalf("the client did not answer %s: %v", body, err) + } + time.Sleep(50 * time.Millisecond) // not listening yet + } +} + +func (r *toolRunner) askConfig(module, key string) Answered { + r.t.Helper() + return r.ask(fmt.Sprintf(`{"module":%q,"kind":"config","key":%q}`, module, key)) +} + +// hear collects what the client says moved on this machine. +func (r *toolRunner) hear() chan string { + r.t.Helper() + heard := make(chan string, 64) + if _, err := r.nc.Subscribe("prov.changed."+node+".>", func(m *nats.Msg) { + heard <- strings.TrimPrefix(m.Subject, "prov.changed."+node+".") + " " + string(m.Data) + }); err != nil { + r.t.Fatal(err) + } + _ = r.nc.Flush() + return heard +} + +// placed writes an assignment of a module the controller has not handed to the provisioner: placed, no plan. +func (b *aBus) placed(t *testing.T, module string) { + t.Helper() + b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z"}`, + node, module)) +} + +func until(t *testing.T, heard chan string, want string) { + t.Helper() + deadline := time.After(10 * time.Second) + for { + select { + case got := <-heard: + if got == want { + return + } + case <-deadline: + t.Fatalf("the client did not say %s", want) + } + } +} + +func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) { + b := startBus(t) + e := startEngine(t, nil) + b.placed(t, "claude-code") + b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":false}`) + runner := aToolRunner(t, b) + heard := runner.hear() + aClient(t, b, e, t.TempDir(), time.Hour) + + // The value as mesh-config resolved it. + if a := runner.askConfig("claude-code", "managed_settings"); a.Error != "" || string(a.Value) != `{"spinnerTipsEnabled":false}` { + t.Fatalf("answered %+v", a) + } + // Coming up, it says every module's config moved, so a watch held across its absence asks again. + until(t, heard, `claude-code {"kind":"config"}`) + + // A change is said, which value and never the value; asked again, the new value. + b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":true}`) + until(t, heard, `claude-code {"kind":"config","key":"managed_settings"}`) + if a := runner.askConfig("claude-code", "managed_settings"); string(a.Value) != `{"spinnerTipsEnabled":true}` { + t.Fatalf("after the change, answered %+v", a) + } + + // Taken off the machine: said, and no longer answered. + b.del(t, AssignmentsBucket, node+".claude-code") + until(t, heard, `claude-code {"kind":"config"}`) + if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on g14") { + t.Fatalf("a module taken off the machine was answered %+v", a) + } +} + +func TestNatsWhatAModulesCodeMayNotReadIsRefusedWithTheReason(t *testing.T) { + b := startBus(t) + e := startEngine(t, nil) + b.placed(t, "claude-code") + b.config(t, "claude-code", "managed_settings", `{}`) + // Resolved for a module the composition does not place on this machine. + b.config(t, "dunst", "font-size", "13") + runner := aToolRunner(t, b) + aClient(t, b, e, t.TempDir(), time.Hour) + + for _, c := range []struct{ ask, refused string }{ + {`{"module":"claude-code","kind":"config","key":"no_such_key"}`, "declares no config value no_such_key"}, + {`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on g14"}, + {`{"module":"claude-code","kind":"secret","key":"gitea-read"}`, "not answered on the tool runner's word"}, + {`{"module":"claude-code","kind":"bound","key":"mcp-endpoint:url"}`, "not answered yet"}, + {`{"module":"claude-code","kind":"setting","key":"role"}`, "not a kind the client answers"}, + {`{"module":"claude-code","kind":"config"}`, "names its module, its kind and its key"}, + {`{"module":"claude-code","kind":"config","key":"managed_settings","as":"dunst"}`, "names its module, its kind and its key"}, + {`not json`, "names its module, its kind and its key"}, + } { + a := runner.ask(c.ask) + if len(a.Value) != 0 || !strings.Contains(a.Error, c.refused) { + t.Errorf("%s: answered %+v, want refused with %q", c.ask, a, c.refused) + } + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bars_golden_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bars_golden_test.go new file mode 100644 index 00000000..8fb7cccb --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bars_golden_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// The bar files i3status-rust's bars come to, filled by this client from power's items of the collection +// contribution:node-bar:block, are byte for byte what the old shaped contribution made (design 57 L3, issue 781). +// The module files are the catalogue's own; testdata/bars holds the bars as composed before the move, for a machine +// with neither capability, the battery, the power meter and both. The controller's side of the same bars, not handed, +// is pinned by mesh-controller internal/catalogue TestTheBarsOfTheCatalogueAreTheSameHandedOrNot. + +func TestNatsTheBarsFilledFromPowersItemsAreWhatTheOldContributionMade(t *testing.T) { + modules := filepath.Join("..", "..", "..") + raw, err := os.ReadFile(filepath.Join(modules, "power", "module.json")) + if err != nil { + t.Fatal(err) + } + var power struct { + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + IfCapability string `json:"if-capability"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &power); err != nil { + t.Fatal(err) + } + for name, caps := range map[string][]string{"none": nil, "battery": {"battery"}, "power-meter": {"power-meter"}, + "both": {"battery", "power-meter"}} { + t.Run(name, func(t *testing.T) { + has := map[string]bool{} + for _, c := range caps { + has[c] = true + } + bar := func(which string) string { + b, err := os.ReadFile(filepath.Join(modules, "i3status-rust", "files", which+"-bar.toml")) + if err != nil { + t.Fatal(err) + } + return string(b) + } + barTpl, _ := templates(t, map[string]string{"bottom": bar("bottom"), "top": bar("top")}) + bars := Plan{Templates: barTpl, Files: []PlannedFile{ + {ID: "bottom", Path: "/bars/bottom.toml", Template: "bottom", OwnerAtRuntime: "owned"}, + {ID: "top", Path: "/bars/top.toml", Template: "top", OwnerAtRuntime: "owned"}}, + Collections: []PlannedCollection{{Name: "contribution:node-bar:block", Comment: "#"}}, + Placement: map[string]string{"machine:name": "workstation"}} + texts := map[string]string{} + var items []PlannedItem + for _, it := range power.Items { + if it.IfCapability != "" && !has[it.IfCapability] { + continue // the controller leaves it out of the plan + } + text, err := os.ReadFile(filepath.Join(modules, "power", it.Template)) + if err != nil { + t.Fatal(err) + } + id := filepath.Base(it.Template) + texts[id] = string(text) + items = append(items, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id, + Attributes: it.Attributes}) + } + itemTpl, _ := templates(t, texts) + contrib := Plan{Templates: itemTpl, Items: items, Placement: map[string]string{"machine:name": "workstation"}} + + b := startBus(t) + e := startEngine(t, map[string]string{"/bars/bottom.toml": "owned", "/bars/top.toml": "owned"}) + b.assign(t, "i3status-rust", bars) + b.assign(t, "power", contrib) + aClient(t, b, e, t.TempDir(), time.Hour) + for _, which := range []string{"bottom", "top"} { + golden, err := os.ReadFile(filepath.Join("testdata", "bars", name+"."+which+"-bar.toml")) + if err != nil { + t.Fatal(err) + } + eventually(t, "the "+which+" bar is what it was", func() bool { + return e.read(t, "/bars/"+which+".toml") == string(golden) + }) + } + }) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go new file mode 100644 index 00000000..23664ee4 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// A handed gitea's server.env (issue 788): its template is the catalogue's own, and its machine's client fills it from +// its provider's entry as the provider's helper now writes it (`as`, and `at` and `from` among the values) and from +// the vault's rows, to the text the controller composed for the same values before it was handed. +const giteaEnvPath = "/etc/gitea/server.env" + +func giteaTemplate(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile("../../../gitea/files/server.env") + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// giteaBoundExpected is that file as the controller rendered it: the database as the binding file told gitea. +func giteaBoundExpected(internalToken, password string) string { + return "GITEA__security__INTERNAL_TOKEN=" + internalToken + "\n" + + "GITEA__database__DB_TYPE=postgres\n" + + "GITEA__database__HOST=10.7.0.1:5432\n" + + "GITEA__database__NAME=mesh_g14_gitea\n" + + "GITEA__database__USER=mesh_g14_gitea\n" + + "GITEA__database__PASSWD=" + password + "\n" + + "GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n" +} + +func giteaRun(t *testing.T, entry string, extra string) (*engine, *aBus, *reports) { + t.Helper() + b := startBus(t) + registryOf679(t, b) + vault := startVault(t, b) + vault.keep("own/g14/gitea/internal-token", "tok-never-on-the-bus", false) + vault.keep("pair/novox/postgres-database/g14/gitea", "pw-never-on-the-bus", false) + heard := b.listen(t) + e := startEngine(t, map[string]string{giteaEnvPath: "owned"}) + tpl, _ := templates(t, map[string]string{"server.env": giteaTemplate(t) + extra}) + b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "env", Path: giteaEnvPath, + Template: "server.env", OwnerAtRuntime: "owned"}}, + Secrets: []PlannedSecret{{Name: "internal-token"}}, + Provisions: []PlannedProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox", + ProviderModule: "postgres", CredentialForm: "pair"}}}) + kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"}) + if err != nil { + t.Fatal(err) + } + b.kv["postgres_bound"] = kv + b.put(t, "postgres_bound", node+".gitea.postgres-database", entry) + aClient(t, b, e, t.TempDir(), time.Hour) + return e, b, heard +} + +func TestNatsAHandedGiteasServerEnvIsFilledAsTheControllerComposedIt(t *testing.T) { + e, _, _ := giteaRun(t, `{"provider":"novox","provider-module":"postgres","provision":"postgres-database",`+ + `"consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+ + `"values":{"at":"10.7.0.1","from":"novox","port":5432},"credential":"pair/novox/postgres-database/g14/gitea"}`, + "FROM=${bound:postgres-database:from}\n") + eventually(t, "server.env is filled to the controller's text", func() bool { + return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=novox\n" + }) +} + +// An entry without `at`, or without `from`, fills nothing: the module fails naming the key, never a file with a hole. +func TestNatsAnEntryWithoutAtOrFromLeavesGiteasServerEnvUnfilled(t *testing.T) { + for key, values := range map[string]string{"at": `{"from":"novox","port":5432}`, "from": `{"at":"10.7.0.1","port":5432}`} { + e, _, heard := giteaRun(t, `{"as":"mesh_g14_gitea","user":"mesh_g14_gitea","values":`+values+`}`, + "FROM=${bound:postgres-database:from}\n") + eventually(t, "the module fails for the missing "+key, func() bool { + r, _ := heard.last("gitea") + return r.Status == StatusFailed && strings.Contains(r.Reason, `no "`+key+`"`) + }) + if got := e.read(t, giteaEnvPath); strings.Contains(got, "GITEA__database__HOST") { + t.Fatalf("without %s the file was filled: %q", key, got) + } + } +} + +// A handed gitea's origins file (issue 792): filled by its machine's client from the route provider's entry, which +// carries the public name the controller composed for gitea, to the text the controller wrote for the binding file. +func TestNatsAHandedGiteasOriginsAreFilledFromTheRouteProvidersEntry(t *testing.T) { + const originsPath = "/etc/gitea/origins" + raw, err := os.ReadFile("../../../gitea/files/origins") + if err != nil { + t.Fatal(err) + } + b := startBus(t) + registryOf679(t, b) + startVault(t, b) + e := startEngine(t, map[string]string{originsPath: "owned"}) + tpl, _ := templates(t, map[string]string{"origins": string(raw)}) + b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath, + Template: "origins", OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-adapter"}}}) + kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-adapter_bound"}) + if err != nil { + t.Fatal(err) + } + b.kv["route-adapter_bound"] = kv + b.put(t, "route-adapter_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-adapter",`+ + `"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+ + `"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "origins is filled to the controller's text", func() bool { + return e.read(t, originsPath) == "https://git.example.org\n" + }) +} + +// The same where route-proxy, not route-adapter, answers the route, as on novox (issue 792): filled by its machine's client from the route provider's entry, which +// carries the public name the controller composed for gitea, to the text the controller wrote for the binding file. +func TestNatsAHandedGiteasOriginsAreFilledFromRouteProxysEntryOnNovox(t *testing.T) { + const originsPath = "/etc/gitea/origins" + raw, err := os.ReadFile("../../../gitea/files/origins") + if err != nil { + t.Fatal(err) + } + b := startBus(t) + registryOf679(t, b) + startVault(t, b) + e := startEngine(t, map[string]string{originsPath: "owned"}) + tpl, _ := templates(t, map[string]string{"origins": string(raw)}) + b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath, + Template: "origins", OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}}) + kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-proxy_bound"}) + if err != nil { + t.Fatal(err) + } + b.kv["route-proxy_bound"] = kv + b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+ + `"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+ + `"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "origins is filled to the controller's text", func() bool { + return e.read(t, originsPath) == "https://git.example.org\n" + }) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go new file mode 100644 index 00000000..42b87570 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go @@ -0,0 +1,1021 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// The provisioner's client on one node (novox/hq ADR 0349 clauses 4 and 5, design 57 P5, issue 671). +// +// For each module the controller handed to the provisioner on this node, the client reads its templates' +// placeholders, records them as the module's needs, watches each owner's keys for this node (a watch delivers the +// current values first), fills the templates only once every value is there — otherwise it keeps the files there +// are and reports what it waits for — has the node-engine write each changed file through its narrow local verb, +// which makes the program take the change as the file declares, and reports `prov.applied` (applied, or failed +// with the reason). It does so again on every change it watches, writes every file again on a drift pass so the +// node-engine puts an owned file back, and when its node comes back it starts from the current values. A module +// taken off the node loses its needs and its report; the node-engine removes the files the declaration no longer +// names. +// +// The owners it fills from are placement (the controller's, in the module's assignment), config +// (mesh-config_resolved) and the vault, asked for each secret the module's plan names (secrets.go), and for each +// consumer's credential a provider here lists (consumers.go); and each provider's entries for a consumer here. Every +// key it writes is its own machine's, and every key it reads but the assignments mirror's, which it reads whole for +// who binds a provider here (ADR 0373): its bus account is granted no other. + +// The buckets the client reads and writes, and the subject it reports on. +const ( + AssignmentsBucket = "mesh-controller_assignments" + ConfigBucket = "mesh-config_resolved" + RegistryBucket = "mesh-provisioner_registry" + NeedsBucket = "mesh-provisioner_needs" + AppliedBucket = "mesh-provisioner_applied" +) + +// AppliedSubject is where the client says what it applied for one module (ADR 0349 clauses 4 and 15). +func AppliedSubject(node, module string) string { return "prov.applied." + node + "." + module } + +// What a report says of a module. +const ( + StatusApplied = "applied" + StatusWaiting = "waiting" + StatusFailed = "failed" + StatusRemoved = "removed" +) + +// Report is what the client says of one module on its node, on prov.applied and in mesh-provisioner_applied. +type Report struct { + Node string `json:"node"` + Module string `json:"module"` + Status string `json:"status"` + // Reason is why it failed. + Reason string `json:"reason,omitempty"` + // WaitsFor is each value it waits for, `:`, and why. + WaitsFor []string `json:"waits-for,omitempty"` + // Templates is the digest of the templates it filled from: which build of the module this is. + Templates string `json:"templates,omitempty"` + Files []FileApplied `json:"files,omitempty"` + // Items are the collections the module's items are recorded in on this node (design 57 L1). + Items []string `json:"items,omitempty"` + At time.Time `json:"at"` +} + +// FileApplied is one file as the node-engine wrote it. +type FileApplied struct { + Path string `json:"path"` + Action string `json:"action,omitempty"` + Digest string `json:"digest,omitempty"` + Takes string `json:"takes,omitempty"` +} + +// NeedRecord is one need a module's templates name, as mesh-provisioner_needs keeps it at +// `...`: the key's name in a form the bus takes, and the name as written here. +type NeedRecord struct { + Node string `json:"node"` + Module string `json:"module"` + Kind string `json:"kind"` + Name string `json:"name"` + Files []string `json:"files"` +} + +// Answer is a registry's entry, as mesh-provisioner keeps it: which domain answers a kind, and where. +type Answer struct { + Kind string `json:"kind"` + Domain string `json:"domain"` + From string `json:"from"` + Bucket string `json:"bucket,omitempty"` + Key string `json:"key,omitempty"` + // Ask is a seat's verb asked on request and reply, `.`: the vault's get for a secret. + Ask string `json:"ask,omitempty"` +} + +// resolved is one value of mesh-config_resolved, as far as filling reads it. +type resolved struct { + Value json.RawMessage `json:"value,omitempty"` +} + +// Client is one node's provisioner client. +type Client struct { + Node string + JS jetstream.JetStream + // Conn is where the reports are published. + Conn *nats.Conn + Fill Filler + // Secrets hands the module's secrets from the vault (secrets.go); nil fails every secret placeholder. + Secrets Secrets + // Templates fetches and keeps the templates archives. + Templates *templateStore + // Drift is how often every applied module's files are written again, so an owned file changed on disk is put + // back; zero is a minute. + Drift time.Duration + // Now dates the reports; nil is time.Now. + Now func() time.Time + + // watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by + // Run. + watchBound func(bucket string) error + + // What it last had written, by path: the content's digest. Kept across runs of Run, so a node coming back + // writes only what moved — and the drift pass writes everything again anyway. + written map[string][32]byte +} + +// state is what one run holds, from its watches. +type state struct { + registry map[string]Answer + plans map[string]Plan + refused map[string]string // a module whose plan cannot be acted on, and why + config map[string]map[string]json.RawMessage + needs map[string][]byte // key → value recorded + applied map[string]bool // module → a report is kept + reported map[string][]byte // module → the last report said, undated + secrets map[string]heldSecret // the vault's name → what this run holds of it; never written anywhere + // items is every item recorded for this node, by collection and contributor: what a collector is filled from. + items map[string]map[string]itemValue + // recorded is each item's key and its value as kept: what this client compares before it records again. + recorded map[string][]byte + assigned map[string]bool // every module assigned to this node, with a plan or not (asks.go) + // binds is every machine's assignment by its key, as far as whom it binds (consumers.go): what a provider here is + // filled with its consumers from. + binds map[string]binding + // providing is each module here whose templates name its consumers (consumers.go), and leftOut what was last said + // of the consumers left out of one of its files. + providing map[string]bool + leftOut map[string]string + // bound is each provider's bucket of entries this client watches, by key: this machine's consumers' entries alone; + // boundReady is a bucket whose current entries are all read, and boundUsers the modules filled from each. + bound map[string]map[string][]byte + boundReady map[string]bool + boundUsers map[string]map[string]bool +} + +type update struct { + bucket string + entry jetstream.KeyValueEntry // nil: the watch delivered every current value + // changed is what the vault said of a secret that changed (secret-changed): never a value. Set, entry is nil. + changed []byte + // asked is the tool runner asking for a module's value (asks.go). Set, entry is nil. + asked *nats.Msg +} + +// Run serves until ctx ends or a watch stops, which it returns: run again, it starts from the current values. +func (c *Client) Run(ctx context.Context) error { + if c.written == nil { + c.written = map[string][32]byte{} + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + kvs := map[string]jetstream.KeyValue{} + for _, b := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket, + CollectBucket} { + kv, err := c.JS.KeyValue(ctx, b) + if err != nil { + return fmt.Errorf("the bucket %s: %w", b, err) + } + kvs[b] = kv + } + updates := make(chan update, 256) + stopped := make(chan error, 64) + watch := func(bucket, keys string) error { + w, err := kvs[bucket].Watch(ctx, keys) + if err != nil { + return fmt.Errorf("watching %s %s: %w", bucket, keys, err) + } + go func() { + defer func() { _ = w.Stop() }() + for { + select { + case <-ctx.Done(): + return + case e, ok := <-w.Updates(): + if !ok { + stopped <- fmt.Errorf("the watch of %s stopped", bucket) + return + } + select { + case updates <- update{bucket: bucket, entry: e}: + case <-ctx.Done(): + return + } + } + } + }() + return nil + } + mine := c.Node + ".>" + // The registry whole, and every machine's assignment: a provider here is bound by consumers on any machine, in + // their plans (ADR 0373). Every other bucket, this machine's keys alone. + for _, b := range []string{RegistryBucket, AssignmentsBucket} { + if err := watch(b, ">"); err != nil { + return err + } + } + for _, b := range []string{ConfigBucket, NeedsBucket, AppliedBucket, CollectBucket} { + if err := watch(b, mine); err != nil { + return err + } + } + // A provider's bucket of entries, watched once a plan here names that provider (consumers.go): this machine's + // consumers' keys alone. A bucket not there yet is tried again at the next fill. + watchedBound := map[string]bool{} + c.watchBound = func(bucket string) error { + if watchedBound[bucket] { + return nil + } + kv, err := c.JS.KeyValue(ctx, bucket) + if err != nil { + return err + } + kvs[bucket] = kv + if err := watch(bucket, mine); err != nil { + return err + } + watchedBound[bucket] = true + return nil + } + defer func() { c.watchBound = nil }() + // What the vault says changed: a secret this run holds is asked for again, so a rotation reaches its file + // without a new declaration. + said, err := c.Conn.Subscribe(SecretChangedSubject, func(m *nats.Msg) { + select { + case updates <- update{bucket: SecretChangedSubject, changed: m.Data}: + case <-ctx.Done(): + } + }) + if err != nil { + return fmt.Errorf("listening for %s: %w", SecretChangedSubject, err) + } + defer func() { _ = said.Unsubscribe() }() + // This machine's tool runner asking for a module's config value (asks.go): answered from this run's state, in + // its loop, so an answer is never read from half an update. + asks, err := c.Conn.Subscribe(AskSubject(c.Node), func(m *nats.Msg) { + select { + case updates <- update{asked: m}: + case <-ctx.Done(): + } + }) + if err != nil { + return fmt.Errorf("listening for %s: %w", AskSubject(c.Node), err) + } + defer func() { _ = asks.Unsubscribe() }() + + s := &state{registry: map[string]Answer{}, plans: map[string]Plan{}, refused: map[string]string{}, + config: map[string]map[string]json.RawMessage{}, needs: map[string][]byte{}, applied: map[string]bool{}, + reported: map[string][]byte{}, secrets: map[string]heldSecret{}, items: map[string]map[string]itemValue{}, + recorded: map[string][]byte{}, assigned: map[string]bool{}, binds: map[string]binding{}, + providing: map[string]bool{}, leftOut: map[string]string{}, bound: map[string]map[string][]byte{}, + boundReady: map[string]bool{}, boundUsers: map[string]map[string]bool{}} + // Every current value first: nothing is filled from half of them. + current := map[string]bool{} + var early []*nats.Msg + for len(current) < 6 { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-stopped: + return err + case u := <-updates: + if u.asked != nil { + // Answered once every current value is in: never from half of them. + early = append(early, u.asked) + continue + } + if u.changed != nil { + // Nothing is held yet: every secret is asked for when it is first filled. + continue + } + if u.entry == nil { + current[u.bucket] = true + continue + } + c.take(s, u) + } + } + // A module that left while this client was away: its needs and its report go. + for _, m := range c.leftModules(s) { + c.leave(ctx, kvs, s, m) + } + // Contributors first, so a collector starting with its node is filled from the items there are now. + for _, m := range contributorsFirst(s.plans) { + c.reconcile(ctx, kvs, s, m, false) + } + for _, m := range sortedKeys(s.refused) { + c.report(ctx, kvs, s, Report{Module: m, Status: StatusFailed, Reason: s.refused[m]}) + } + // What was asked meanwhile, and every module's config said to have moved: a watch held across this client's + // absence is asked again from the current values. + for _, m := range early { + c.respond(s, m) + } + for _, m := range sortedKeys(s.config) { + c.tell(m, Changed{Kind: AskConfig}) + } + + drift := c.Drift + if drift <= 0 { + drift = time.Minute + } + tick := time.NewTicker(drift) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-stopped: + return err + case <-tick.C: + for _, m := range sortedKeys(s.plans) { + c.reconcile(ctx, kvs, s, m, true) + } + case u := <-updates: + // What moved together is filled together: every update waiting is taken before anything is filled. + dirty := map[string]bool{} + for { + switch { + case u.asked != nil: + c.respond(s, u.asked) + case u.changed != nil: + for _, m := range c.secretChanged(s, u.changed) { + dirty[m] = true + } + case u.entry != nil: + for _, m := range c.take(s, u) { + dirty[m] = true + } + c.told(u) + case strings.HasSuffix(u.bucket, boundSuffix): + // A provider's bucket's current entries are all read: what waited on them is filled. + s.boundReady[u.bucket] = true + for m := range s.boundUsers[u.bucket] { + dirty[m] = true + } + } + select { + case u = <-updates: + continue + case <-time.After(50 * time.Millisecond): + } + break + } + for _, m := range contributorsFirst(onlyThese(s.plans, dirty)) { + switch { + case s.refused[m] != "": + c.report(ctx, kvs, s, Report{Module: m, Status: StatusFailed, Reason: s.refused[m]}) + case hasPlan(s, m): + c.reconcile(ctx, kvs, s, m, false) + default: + c.leave(ctx, kvs, s, m) + } + } + } + } +} + +func hasPlan(s *state, m string) bool { _, ok := s.plans[m]; return ok } + +// told says what an update moved of a module's config, or of its placement, which decides whether it is answered +// at all (asks.go). +func (c *Client) told(u update) { + rest, ok := strings.CutPrefix(u.entry.Key(), c.Node+".") + if !ok { + return + } + switch u.bucket { + case ConfigBucket: + if module, name, ok := strings.Cut(rest, "."); ok { + c.tell(module, Changed{Kind: AskConfig, Key: name}) + } + case AssignmentsBucket: + c.tell(rest, Changed{Kind: AskConfig}) + } +} + +// onlyThese is the modules of dirty, each with its plan where it has one: what contributorsFirst orders. +func onlyThese(plans map[string]Plan, dirty map[string]bool) map[string]Plan { + out := map[string]Plan{} + for m := range dirty { + out[m] = plans[m] + } + return out +} + +// contributorsFirst is the modules in name order, those contributing an item before the others: a contributor's item +// recorded first is in its collector's file the first time it is filled. +func contributorsFirst(plans map[string]Plan) []string { + var first, then []string + for _, m := range sortedKeys(plans) { + if len(plans[m].Items) > 0 { + first = append(first, m) + } else { + then = append(then, m) + } + } + return append(first, then...) +} + +// take folds one update into the state, and answers the modules it may change. +func (c *Client) take(s *state, u update) []string { + e := u.entry + gone := e.Operation() != jetstream.KeyValuePut + key := e.Key() + switch u.bucket { + case RegistryBucket: + if gone { + delete(s.registry, key) + } else { + var a Answer + if err := json.Unmarshal(e.Value(), &a); err != nil { + logf("[provisioner-client] the registry's %s cannot be read, so the kind is not answered: %v", key, err) + delete(s.registry, key) + } else { + s.registry[key] = a + } + } + // A kind's answer moves what every module waits on. + return sortedKeys(s.plans) + case AssignmentsBucket: + // Whom it binds, whichever machine's it is: a provider here listing its consumers is filled again. + var providers []string + if takeBinds(s, key, e.Value(), gone) { + providers = sortedKeys(s.providing) + } + module, mine := strings.CutPrefix(key, c.Node+".") + if !mine { + return providers + } + delete(s.plans, module) + delete(s.refused, module) + if gone { + delete(s.assigned, module) + return append(providers, module) + } + s.assigned[module] = true + m, p, err := planOf(c.Node, key, e.Value()) + switch { + case errors.Is(err, errNoPlan): + case err != nil: + s.refused[module] = err.Error() + logf("[provisioner-client] %v", err) + default: + s.plans[m] = p + } + return append(providers, module) + case ConfigBucket: + rest := strings.TrimPrefix(key, c.Node+".") + module, name, ok := strings.Cut(rest, ".") + if !ok { + return nil + } + if s.config[module] == nil { + s.config[module] = map[string]json.RawMessage{} + } + var r resolved + if gone || json.Unmarshal(e.Value(), &r) != nil || len(r.Value) == 0 { + delete(s.config[module], name) + } else { + s.config[module][name] = r.Value + } + return []string{module} + case NeedsBucket: + if gone { + delete(s.needs, key) + } else { + s.needs[key] = e.Value() + } + case AppliedBucket: + module := strings.TrimPrefix(key, c.Node+".") + s.applied[module] = !gone + case CollectBucket: + collection, contributor, ok := itemOfKey(c.Node, key) + if !ok { + logf("[provisioner-client] %s in %s is not .., and is not read", key, + CollectBucket) + return nil + } + var it ItemRecord + if gone { + delete(s.recorded, key) + delete(s.items[collection], contributor) + } else if err := json.Unmarshal(e.Value(), &it); err != nil { + logf("[provisioner-client] the item %s cannot be read, so it is left out: %v", key, err) + delete(s.recorded, key) + delete(s.items[collection], contributor) + } else { + s.recorded[key] = e.Value() + if s.items[collection] == nil { + s.items[collection] = map[string]itemValue{} + } + s.items[collection][contributor] = itemValue{text: it.Text, attributes: it.Attributes} + } + // Every collector of it on this node is filled again. + var collectors []string + for _, m := range sortedKeys(s.plans) { + if s.plans[m].collects(collection) { + collectors = append(collectors, m) + } + } + return collectors + } + if strings.HasSuffix(u.bucket, boundSuffix) { + // A provider's entry for one of this machine's consumers: each module filled from that bucket again. + if s.bound[u.bucket] == nil { + s.bound[u.bucket] = map[string][]byte{} + } + if gone { + delete(s.bound[u.bucket], key) + } else { + s.bound[u.bucket][key] = e.Value() + } + return sortedKeys(s.boundUsers[u.bucket]) + } + return nil +} + +// leftModules are the modules with needs or a report kept and no assignment with a plan. +func (c *Client) leftModules(s *state) []string { + left := map[string]bool{} + for key := range s.needs { + if parts := strings.SplitN(key, ".", 3); len(parts) == 3 && !hasPlan(s, parts[1]) && s.refused[parts[1]] == "" { + left[parts[1]] = true + } + } + for m, kept := range s.applied { + if kept && !hasPlan(s, m) && s.refused[m] == "" { + left[m] = true + } + } + for key := range s.recorded { + if _, m, ok := itemOfKey(c.Node, key); ok && !hasPlan(s, m) && s.refused[m] == "" { + left[m] = true + } + } + return sortedKeys(left) +} + +// leave removes a module's needs and its report: it is no longer the provisioner's on this node. +func (c *Client) leave(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string) { + prefix := c.Node + "." + module + "." + removed := 0 + for _, key := range sortedKeys(s.needs) { + if strings.HasPrefix(key, prefix) { + if err := kvs[NeedsBucket].Delete(ctx, key); err != nil { + logf("[provisioner-client] the need %s could not be removed: %v", key, err) + continue + } + delete(s.needs, key) + removed++ + } + } + // Its items go: an item is in its collection only while its contributor is placed here. + removed += c.forgetItems(ctx, kvs, s, module, nil) + if !s.applied[module] && removed == 0 && s.reported[module] == nil { + return + } + if err := kvs[AppliedBucket].Delete(ctx, c.Node+"."+module); err != nil { + logf("[provisioner-client] the report of %s could not be removed: %v", module, err) + } + s.applied[module] = false + delete(s.reported, module) + c.publish(Report{Node: c.Node, Module: module, Status: StatusRemoved, At: c.now()}) + logf("[provisioner-client] %s left %s: its %d need(s) and item(s) and its report are removed", module, c.Node, + removed) +} + +// reconcile fills one module's files from the current values, and says what came of it. force writes every file +// again, whether it moved or not: the drift pass. +func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string, + force bool) { + p := s.plans[module] + r := Report{Module: module, Templates: p.Templates.Digest} + texts, err := c.Templates.load(ctx, p.Templates) + if err != nil { + r.Status, r.Reason = StatusFailed, err.Error() + c.report(ctx, kvs, s, r) + return + } + // The needs, file by file: each template, and each file holding one secret alone. + targets, wrong := fillTargets(p, texts) + if wrong != "" { + r.Status, r.Reason = StatusFailed, wrong + c.report(ctx, kvs, s, r) + return + } + items, wrong := itemTargets(p, texts) + if wrong != "" { + r.Status, r.Reason = StatusFailed, wrong + c.report(ctx, kvs, s, r) + return + } + byFile := map[string]string{} + needs := map[Need][]string{} + var order []Need + named := func(id, text string) { + for _, n := range needsOf(text) { + if needs[n] == nil { + order = append(order, n) + } + needs[n] = append(needs[n], id) + } + } + for _, f := range targets { + byFile[f.Path] = f.text + named(f.ID, f.text) + } + for _, it := range items { + named(it.id(), it.text) + } + c.recordNeeds(ctx, kvs, s, module, order, needs) + + values := map[Need]string{} + var failed []string + for _, n := range order { + v, wait, fail := c.valueOf(ctx, s, module, p, n) + switch { + case fail != "": + failed = append(failed, fail) + case wait != "": + r.WaitsFor = append(r.WaitsFor, wait) + default: + values[n] = v + } + } + if len(failed) > 0 { + r.Status, r.Reason = StatusFailed, strings.Join(failed, "; ") + c.report(ctx, kvs, s, r) + return + } + if len(r.WaitsFor) > 0 { + // Every value or none: the files there are stay as they are. + r.Status = StatusWaiting + c.report(ctx, kvs, s, r) + return + } + // The items first: recorded, they fill the collectors, this module's own among them. + for _, it := range items { + if err := c.record(ctx, kvs, s, module, p, it.PlannedItem, render(it.text, values)); err != nil { + failed = append(failed, fmt.Sprintf("item %s: %v", it.key(), err)) + continue + } + r.Items = append(r.Items, it.key()) + } + keep := map[string]bool{} + for _, it := range p.Items { + keep[it.key()] = true + } + c.forgetItems(ctx, kvs, s, module, keep) + moved := false + for _, f := range targets { + content := []byte(render(byFile[f.Path], values)) + sum := sha256.Sum256(content) + fa := FileApplied{Path: f.Path, Takes: f.Takes} + if !force && c.written[f.Path] == sum { + fa.Action = "unchanged" + r.Files = append(r.Files, fa) + continue + } + a, err := c.Fill.Fill(ctx, f.Path, content) + if err == nil { + err = errFill(a) + } + if err != nil { + delete(c.written, f.Path) + failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err)) + continue + } + c.written[f.Path] = sum + fa.Action, fa.Digest = a.Action, a.Digest + if a.Action != "unchanged" && a.Action != "kept" { + moved = true + logf("[provisioner-client] %s: %s %s (%s)", module, a.Action, f.Path, a.Digest) + } + r.Files = append(r.Files, fa) + } + if len(failed) > 0 { + r.Status, r.Reason = StatusFailed, strings.Join(failed, "; ") + } else { + r.Status = StatusApplied + } + if force && !moved && r.Status == StatusApplied { + // The drift pass found every file as it should be: nothing to say. + return + } + if force || moved { + // Said again, even when it reads the same: a file was put back. + delete(s.reported, module) + } + c.report(ctx, kvs, s, r) +} + +// valueOf is a need's value, or what it waits for, or why it cannot be filled. +func (c *Client) valueOf(ctx context.Context, s *state, module string, p Plan, n Need) (value, wait, fail string) { + answer, known := s.registry[n.Kind] + switch { + case slices.Contains(placementKinds, n.Kind): + if !known { + return "", n.String() + " (no domain answers " + n.Kind + " in the registry yet)", "" + } + v, ok := p.Placement[n.String()] + if !ok { + return "", "", n.String() + ": the controller's plan carries no value for it" + } + return v, "", "" + case n.Kind == "config": + if !known { + return "", n.String() + " (no domain answers config in the registry yet)", "" + } + if answer.Bucket != ConfigBucket { + return "", "", fmt.Sprintf("%s: the registry answers config from %q, which this client does not watch", + n, answer.Bucket) + } + v, ok := s.config[module][n.Name] + if !ok { + return "", n.String() + " (mesh-config holds no value for it on this node)", "" + } + return configText(v), "", "" + case collectionKind(n): + collection, _ := collectionOf(n) + if !known { + return "", n.String() + " (no domain answers " + n.Kind + " in the registry yet)", "" + } + if answer.Bucket != CollectBucket { + return "", "", fmt.Sprintf("%s: the registry answers %s from %q, which this client does not watch", n, + n.Kind, answer.Bucket) + } + col, ok := p.collectionNamed(collection) + if !ok { + return "", "", fmt.Sprintf("%s: the plan names no collection %s; a module is filled only from the "+ + "collections its plan says it collects", n, collection) + } + if renderer, is := environmentRenderer(n); is { + text, err := environmentText(s.items[collection], renderer) + if err != nil { + return "", "", fmt.Sprintf("%s: %v", n, err) + } + return text, "", "" + } + where, err := selectorOfNeed(n) + if err != nil { + return "", "", fmt.Sprintf("%s: %v", n, err) + } + return collectionText(s.items[collection], col.Comment, where), "", "" + case n.Kind == "secret": + if !known { + return "", n.String() + " (no domain answers secret in the registry yet)", "" + } + if answer.Ask != VaultSeat+"."+GetVerb { + return "", "", fmt.Sprintf("%s: the registry answers secret from %q, which this client does not ask", n, + answer.Ask) + } + return c.secretOf(ctx, s, module, p, n) + case n.Kind == ConsumersKind: + // A provider's consumers' credentials, each the vault's (ADR 0373). + if !known { + return "", n.String() + " (no domain answers consumers in the registry yet)", "" + } + if answer.Ask != VaultSeat+"."+GetVerb { + return "", "", fmt.Sprintf("%s: the registry answers consumers from %q, which this client does not ask", + n, answer.Ask) + } + return c.consumersOf(ctx, s, module, n) + case n.Kind == BoundKind: + // A consumer's values, in its provider's entry (ADR 0349 clause 12). + if !known { + return "", n.String() + " (no domain answers bound in the registry yet)", "" + } + return c.boundOf(s, module, p, n, answer) + } + return "", "", fmt.Sprintf("%s: this client does not fill %s placeholders yet", n, n.Kind) +} + +// recordNeeds makes the module's needs in mesh-provisioner_needs what its templates name: each need written when it +// is new or names other files, each one no longer named removed. +func (c *Client) recordNeeds(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string, + order []Need, files map[Need][]string) { + want := map[string][]byte{} + for _, n := range order { + v, _ := json.Marshal(NeedRecord{Node: c.Node, Module: module, Kind: n.Kind, Name: n.Name, Files: files[n]}) + want[NeedKey(c.Node, module, n)] = v + } + for _, key := range sortedKeys(want) { + if bytes.Equal(s.needs[key], want[key]) { + continue + } + if _, err := kvs[NeedsBucket].Put(ctx, key, want[key]); err != nil { + logf("[provisioner-client] the need %s could not be recorded: %v", key, err) + continue + } + s.needs[key] = want[key] + } + prefix := c.Node + "." + module + "." + for _, key := range sortedKeys(s.needs) { + if _, still := want[key]; strings.HasPrefix(key, prefix) && !still { + if err := kvs[NeedsBucket].Delete(ctx, key); err != nil { + logf("[provisioner-client] the need %s could not be removed: %v", key, err) + continue + } + delete(s.needs, key) + } + } +} + +// NeedKey is a need's key in mesh-provisioner_needs, `...`: the name with every +// character a key cannot hold, and `=`, written `=`, so `${seat:mesh-broker:4222}` is `seat.mesh-broker=3A4222`. +func NeedKey(node, module string, n Need) string { + var b strings.Builder + for _, r := range []byte(n.Name) { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '/', r == '.': + b.WriteByte(r) + default: + fmt.Fprintf(&b, "=%02X", r) + } + } + return node + "." + module + "." + n.Kind + "." + b.String() +} + +// report says a module's report on prov.applied and keeps it in mesh-provisioner_applied, when it says something +// other than the last. +func (c *Client) report(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, r Report) { + r.Node = c.Node + undated, _ := json.Marshal(r) + if bytes.Equal(s.reported[r.Module], undated) { + return + } + r.At = c.now() + body, err := json.Marshal(r) + if err != nil { + logf("[provisioner-client] the report of %s: %v", r.Module, err) + return + } + if _, err := kvs[AppliedBucket].Put(ctx, c.Node+"."+r.Module, body); err != nil { + logf("[provisioner-client] the report of %s could not be kept: %v", r.Module, err) + } else { + s.applied[r.Module] = true + } + c.publish(r) + s.reported[r.Module] = undated + switch r.Status { + case StatusFailed: + logf("[provisioner-client] %s failed: %s", r.Module, r.Reason) + case StatusWaiting: + logf("[provisioner-client] %s waits for %s", r.Module, strings.Join(r.WaitsFor, ", ")) + } +} + +func (c *Client) publish(r Report) { + body, err := json.Marshal(r) + if err != nil { + return + } + if err := c.Conn.Publish(AppliedSubject(c.Node, r.Module), body); err != nil { + logf("[provisioner-client] prov.applied for %s could not be said: %v", r.Module, err) + } +} + +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now().UTC() + } + return time.Now().UTC() +} + +// ItemKey is an item among its module's: the collection, and `/` for a named item. +func ItemKey(collection, name string) string { + if name == "" { + return collection + } + return collection + "/" + name +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// fillTarget is one file the client fills for a module: a template's, or one holding a secret alone. +type fillTarget struct { + PlannedFile + text string +} + +// fillTargets is every file a plan fills, in its order: each template file, then each file of a secret of the +// module's own or of a credential it requires (issue 757), whose text is that secret and nothing else. Or why one +// cannot be filled. +func fillTargets(p Plan, texts map[string]string) ([]fillTarget, string) { + var out []fillTarget + for _, f := range p.Files { + text, ok := texts[f.Template] + if !ok { + return nil, fmt.Sprintf("%s: the templates %s hold no %s", f.ID, p.Templates.Digest, f.Template) + } + out = append(out, fillTarget{PlannedFile: f, text: text}) + } + for _, sec := range p.Secrets { + if sec.Path == "" { + continue + } + out = append(out, fillTarget{PlannedFile: PlannedFile{ID: "secret." + sec.Name, Path: sec.Path, + OwnerAtRuntime: "owned"}, text: "${secret:" + sec.Name + "}"}) + } + for _, pr := range p.Provisions { + if pr.Path == "" { + continue + } + out = append(out, fillTarget{PlannedFile: PlannedFile{ID: "secret." + pr.Name, Path: pr.Path, + OwnerAtRuntime: "owned"}, text: "${secret:" + pr.Name + "}"}) + } + return out, "" +} + +func collectionKind(n Need) bool { _, ok := collectionOf(n); return ok } + +// itemTarget is one item a module contributes, with its template's text. +type itemTarget struct { + PlannedItem + text string +} + +func (it itemTarget) id() string { return "item." + it.key() } + +// itemTargets is every item a plan contributes, with its template's text, or why one cannot be filled. An item is +// kept in a bucket, so a template naming a secret is refused: no secret is in a bucket (ADR 0349 clause 7). +func itemTargets(p Plan, texts map[string]string) ([]itemTarget, string) { + var out []itemTarget + for _, it := range p.Items { + text, ok := texts[it.Template] + if !ok { + return nil, fmt.Sprintf("item %s: the templates %s hold no %s", it.key(), p.Templates.Digest, + it.Template) + } + for _, n := range needsOf(text) { + if n.Kind == "secret" || n.Kind == ConsumersKind { + return nil, fmt.Sprintf("item %s: its template %s names %s, and an item is kept in a bucket, where no "+ + "secret is (novox/hq ADR 0349 clause 7)", it.key(), it.Template, n) + } + } + out = append(out, itemTarget{PlannedItem: it, text: text}) + } + return out, "" +} + +// record keeps one item of the module's in mesh-provisioner_collect, when it differs from what is kept. +func (c *Client) record(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string, p Plan, + planned PlannedItem, text string) error { + collection, id := planned.Collection, itemID(module, planned.Name) + key := CollectKey(c.Node, collection, id) + value, err := json.Marshal(ItemRecord{Node: c.Node, Collection: collection, Contributor: module, Text: text, + Name: planned.Name, Attributes: planned.Attributes, Templates: p.Templates.Digest}) + if err != nil { + return err + } + if bytes.Equal(s.recorded[key], value) { + return nil + } + if _, err := kvs[CollectBucket].Put(ctx, key, value); err != nil { + return fmt.Errorf("it could not be recorded: %w", err) + } + // Taken now, not only when the watch brings it back: the module's own templates may collect it. + s.recorded[key] = value + if s.items[collection] == nil { + s.items[collection] = map[string]itemValue{} + } + s.items[collection][id] = itemValue{text: text, attributes: planned.Attributes} + logf("[provisioner-client] %s: its item %s is recorded", module, planned.key()) + return nil +} + +// forgetItems removes the module's items of every collection keep does not name (nil: every one), and answers how +// many. +func (c *Client) forgetItems(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string, + keep map[string]bool) int { + removed := 0 + for _, key := range sortedKeys(s.recorded) { + collection, id, ok := itemOfKey(c.Node, key) + if owner, name := splitID(id); !ok || owner != module || keep[ItemKey(collection, name)] { + continue + } + if err := kvs[CollectBucket].Delete(ctx, key); err != nil { + logf("[provisioner-client] the item %s could not be removed: %v", key, err) + continue + } + delete(s.recorded, key) + delete(s.items[collection], id) + removed++ + } + return removed +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go new file mode 100644 index 00000000..c4c69730 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go @@ -0,0 +1,649 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// The client's test list of novox/hq ADR 0349 ("How it is checked"), against a real bus (design 57 P5, issue 671): +// a template fills from each owner's keys; a missing value keeps the old file and reports what it waits for; a change +// refills, writes once and triggers the declared reaction; an owned file changed on disk is written back, a seed +// file is left, a managed file keeps the program's other keys; a module taken off the node loses its files and +// needs; a node coming back fills from current values. +// +// The bus is a real server, its accounts the client's as mesh-controller composes them (testdata/accounts.conf), so +// every read and write the client makes is one its grants allow. The node-engine is a stand-in on a real unix +// socket speaking its format (mesh-host internal/filling), writing each file as its ownership says — the +// node-engine's own tests prove its writes; these prove the client asks for the right ones — and counting each +// write that moved a file, which is what the node-engine reloads or restarts the program for. + +const node = "g14" + +// aBus is a server of this test alone with the client's buckets, the registry written as mesh-provisioner writes it. +type aBus struct { + url string + domain jetstream.JetStream + conn *nats.Conn + kv map[string]jetstream.KeyValue +} + +func startBus(t *testing.T) *aBus { + t.Helper() + accounts, err := os.ReadFile("testdata/accounts.conf") + if err != nil { + t.Fatal(err) + } + conf := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+t.TempDir()+"\" }\n"+ + string(accounts)), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatal(err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + nc, err := nats.Connect(s.ClientURL(), nats.UserInfo("domain", "x")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + b := &aBus{url: s.ClientURL(), domain: js, conn: nc, kv: map[string]jetstream.KeyValue{}} + for _, name := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket, + CollectBucket} { + kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name}) + if err != nil { + t.Fatal(err) + } + b.kv[name] = kv + } + b.put(t, RegistryBucket, "config", `{"kind":"config","domain":"mesh-config","from":"bucket",`+ + `"bucket":"mesh-config_resolved","key":".."}`) + b.put(t, RegistryBucket, "secret", `{"kind":"secret","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`) + for _, kind := range append([]string{CollectedKind}, oldCollectionKinds...) { + b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-provisioner","from":"bucket",`+ + `"bucket":"mesh-provisioner_collect","key":".."}`) + } + for _, kind := range placementKinds { + b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-controller","from":"bucket",`+ + `"bucket":"mesh-controller_assignments","key":"."}`) + } + return b +} + +func (b *aBus) put(t *testing.T, bucket, key, value string) { + t.Helper() + if _, err := b.kv[bucket].PutString(context.Background(), key, value); err != nil { + t.Fatalf("writing %s in %s: %v", key, bucket, err) + } +} + +func (b *aBus) del(t *testing.T, bucket, key string) { + t.Helper() + if err := b.kv[bucket].Delete(context.Background(), key); err != nil { + t.Fatal(err) + } +} + +// config writes a resolved value as mesh-config does. +func (b *aBus) config(t *testing.T, module, key, value string) { + t.Helper() + b.put(t, ConfigBucket, node+"."+module+"."+key, fmt.Sprintf(`{"node":%q,"module":%q,"key":%q,"value":%s,`+ + `"source":"node"}`, node, module, key, value)) +} + +// assign writes a module's assignment with its plan, as the controller does. +func (b *aBus) assign(t *testing.T, module string, plan Plan) { + t.Helper() + p, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ + `"provisioning":%s}`, node, module, p)) +} + +// keys is the bucket's keys under prefix. +func (b *aBus) keys(t *testing.T, bucket, prefix string) []string { + t.Helper() + all, err := b.kv[bucket].Keys(context.Background()) + if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { + t.Fatal(err) + } + var out []string + for _, k := range all { + if strings.HasPrefix(k, prefix) { + out = append(out, k) + } + } + return out +} + +// reports collects what is said on prov.applied. +type reports struct { + mu sync.Mutex + all []Report +} + +func (b *aBus) listen(t *testing.T) *reports { + t.Helper() + r := &reports{} + sub, err := b.conn.Subscribe("prov.applied."+node+".>", func(m *nats.Msg) { + var rep Report + if err := json.Unmarshal(m.Data, &rep); err == nil { + r.mu.Lock() + r.all = append(r.all, rep) + r.mu.Unlock() + } + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + _ = b.conn.Flush() + return r +} + +func (r *reports) last(module string) (Report, int) { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + var out Report + for _, rep := range r.all { + if rep.Module == module { + out, n = rep, n+1 + } + } + return out, n +} + +// engine stands in for the node-engine's write socket: each path written under root, as its ownership says. +type engine struct { + root string + socket string + mu sync.Mutex + owner map[string]string // path → owned, seed or managed + moved map[string]int // writes that changed the file: what the program is reloaded for + asked map[string]int + wrote map[string]string // what it last wrote +} + +func startEngine(t *testing.T, owners map[string]string) *engine { + t.Helper() + // A unix socket's path is short: under /tmp, not the test's own directory. + dir, err := os.MkdirTemp("/tmp", "p5-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + e := &engine{root: t.TempDir(), socket: filepath.Join(dir, "fill.sock"), owner: owners, + moved: map[string]int{}, asked: map[string]int{}, wrote: map[string]string{}} + l, err := net.Listen("unix", e.socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + var req FillRequest + dec := json.NewDecoder(conn) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + _ = json.NewEncoder(conn).Encode(FillAnswer{Refused: "unreadable: " + err.Error()}) + return + } + _ = json.NewEncoder(conn).Encode(e.write(req)) + }() + } + }() + return e +} + +func (e *engine) at(path string) string { return filepath.Join(e.root, path) } + +func (e *engine) write(req FillRequest) FillAnswer { + e.mu.Lock() + defer e.mu.Unlock() + e.asked[req.Path]++ + owner, declared := e.owner[req.Path] + if !declared { + return FillAnswer{Path: req.Path, Refused: req.Path + " is not a file this node's declaration names as filled"} + } + at := e.at(req.Path) + have, err := os.ReadFile(at) + exists := err == nil + want := string(req.Content) + switch owner { + case "seed": + if exists { + return FillAnswer{Path: req.Path, Action: "kept"} + } + case "managed": + // The mesh's block between its marks; the program's lines around it kept. + const begin, end = "# >>> mesh\n", "# <<< mesh\n" + block := begin + want + end + if exists { + text := string(have) + if i, j := strings.Index(text, begin), strings.Index(text, end); i >= 0 && j > i { + block = text[:i] + block + text[j+len(end):] + } else { + block = text + block + } + } + want = block + } + action := "created" + switch { + case exists && string(have) == want: + return FillAnswer{Path: req.Path, Action: "unchanged", Digest: digestOf(have)} + case exists && string(have) != e.wrote[req.Path]: + action = "corrected" + case exists: + action = "updated" + } + _ = os.MkdirAll(filepath.Dir(at), 0o755) + if err := os.WriteFile(at, []byte(want), 0o644); err != nil { + return FillAnswer{Path: req.Path, Failed: err.Error()} + } + e.wrote[req.Path] = want + e.moved[req.Path]++ + return FillAnswer{Path: req.Path, Action: action, Digest: digestOf([]byte(want))} +} + +func (e *engine) counts(path string) (moved, asked int) { + e.mu.Lock() + defer e.mu.Unlock() + return e.moved[path], e.asked[path] +} + +func (e *engine) read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(e.at(path)) + if err != nil { + return "" + } + return string(b) +} + +// templates is an artifact store holding one archive per name, and the plan's Templates for it. +func templates(t *testing.T, files map[string]string) (Templates, *httptest.Server) { + t.Helper() + var buf bytes.Buffer + z := gzip.NewWriter(&buf) + w := tar.NewWriter(z) + for _, name := range sortedKeys(files) { + if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(files[name])), + Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(files[name])); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := z.Close(); err != nil { + t.Fatal(err) + } + body := buf.Bytes() + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write(body) })) + t.Cleanup(srv.Close) + return Templates{Source: srv.URL + "/files.tar.gz", Digest: digestOf(body)}, srv +} + +// aClient runs a client on g14's account until the returned stop is called. +func aClient(t *testing.T, b *aBus, e *engine, state string, drift time.Duration) (*Client, func()) { + t.Helper() + nc, err := nats.Connect(b.url, nats.UserInfo("provisioner."+node, "x"), + nats.CustomInboxPrefix("_INBOX.provisioner."+node), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { + t.Errorf("the bus refused the client: %v", err) + })) + if err != nil { + t.Fatal(err) + } + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + secrets, err := NewVaultSecrets(nc, "provisioner."+node) + if err != nil { + t.Fatal(err) + } + secrets.Within = 2 * time.Second + c := &Client{Node: node, JS: js, Conn: nc, Drift: drift, Secrets: secrets, + Fill: SocketFiller{Socket: e.socket, Busy: 10 * time.Millisecond, Tries: 3}, + Templates: &templateStore{dir: state, fetch: HTTPFetch}} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- c.Run(ctx) }() + stopped := false + stop := func() { + if stopped { + return + } + stopped = true + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Errorf("the client stopped with %v", err) + } + nc.Close() + } + t.Cleanup(stop) + return c, stop +} + +func eventually(t *testing.T, what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !ok() { + if time.Now().After(deadline) { + t.Fatalf("not within 10s: %s", what) + } + time.Sleep(20 * time.Millisecond) + } +} + +const dunstrc = "/home/nox/.config/dunst/dunstrc" + +// dunst is a module with one owned file naming a config value and a placement value. +func dunst(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{ + "dunstrc": "[global]\nfont = Monospace ${config:font-size}\nicons = ${machine:account-home}/.icons\n"}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "dunstrc", Path: dunstrc, Template: "dunstrc", + OwnerAtRuntime: "owned", Takes: "reload-on"}}, + Placement: map[string]string{"machine:account-home": "/home/nox"}} +} + +func TestNatsATemplateFillsFromEachOwnersKeys(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned"}) + b.config(t, "dunst", "font-size", "13") + b.assign(t, "dunst", dunst(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + want := "[global]\nfont = Monospace 13\nicons = /home/nox/.icons\n" + eventually(t, "dunstrc is filled", func() bool { return e.read(t, dunstrc) == want }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusApplied + }) + r, _ := heard.last("dunst") + if r.Templates == "" || len(r.Files) != 1 || r.Files[0].Action != "created" || r.Files[0].Takes != "reload-on" { + t.Errorf("prov.applied said %+v", r) + } + // The needs, one per placeholder, under the node's and the module's name. + eventually(t, "the needs are recorded", func() bool { + got := b.keys(t, NeedsBucket, node+".dunst.") + return len(got) == 2 && got[0] == "g14.dunst.config.font-size" && got[1] == "g14.dunst.machine.account-home" + }) + kept, err := b.kv[AppliedBucket].Get(context.Background(), node+".dunst") + if err != nil || !strings.Contains(string(kept.Value()), `"status":"applied"`) { + t.Errorf("mesh-provisioner_applied keeps %v (%v)", kept, err) + } +} + +func TestNatsAMissingValueKeepsTheOldFileAndSaysWhatItWaitsFor(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned"}) + b.assign(t, "dunst", dunst(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + // No value yet: nothing written, and the wait said. + eventually(t, "prov.applied says it waits", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusWaiting + }) + r, _ := heard.last("dunst") + if len(r.WaitsFor) != 1 || !strings.HasPrefix(r.WaitsFor[0], "config:font-size") { + t.Errorf("it waits for %v", r.WaitsFor) + } + if _, asked := e.counts(dunstrc); asked != 0 { + t.Errorf("a file waiting for a value was written %d times", asked) + } + // Filled once it is there; and a value gone keeps the file there is. + b.config(t, "dunst", "font-size", "13") + eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") }) + b.del(t, ConfigBucket, node+".dunst.font-size") + eventually(t, "prov.applied says it waits again", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusWaiting + }) + if !strings.Contains(e.read(t, dunstrc), "Monospace 13") { + t.Errorf("a value gone changed the file there was: %q", e.read(t, dunstrc)) + } + if _, asked := e.counts(dunstrc); asked != 1 { + t.Errorf("dunstrc was asked for %d times, want 1", asked) + } +} + +func TestNatsAChangeRefillsWritesOnceAndTheProgramTakesIt(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned"}) + b.config(t, "dunst", "font-size", "13") + b.assign(t, "dunst", dunst(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") }) + + b.config(t, "dunst", "font-size", "15") + eventually(t, "dunstrc takes the change", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 15") }) + eventually(t, "prov.applied says it", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusApplied && len(r.Files) == 1 && r.Files[0].Action == "updated" + }) + // The same value again, and a key the template does not name: nothing written. + b.config(t, "dunst", "font-size", "15") + b.config(t, "dunst", "geometry", `"300x5"`) + time.Sleep(300 * time.Millisecond) + if moved, asked := e.counts(dunstrc); moved != 2 || asked != 2 { + t.Errorf("two values wrote dunstrc %d times (asked %d); want 2 and 2: the program is reloaded once a change", + moved, asked) + } +} + +func TestNatsAnOwnedFileIsWrittenBackASeedLeftAndAManagedFileKeepsTheProgramsLines(t *testing.T) { + const seed, managed = "/home/nox/.config/app/first.conf", "/home/nox/.config/app/settings.ini" + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned", seed: "seed", managed: "managed"}) + tpl, _ := templates(t, map[string]string{"dunstrc": "font = ${config:font-size}\n", + "first.conf": "start = ${config:font-size}\n", "settings.ini": "size = ${config:font-size}\n"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", Plan{Templates: tpl, Files: []PlannedFile{ + {ID: "rc", Path: dunstrc, Template: "dunstrc", OwnerAtRuntime: "owned"}, + {ID: "first", Path: seed, Template: "first.conf", OwnerAtRuntime: "seed"}, + {ID: "settings", Path: managed, Template: "settings.ini", OwnerAtRuntime: "managed", Takes: "read-on-use"}}}) + aClient(t, b, e, t.TempDir(), 200*time.Millisecond) + eventually(t, "all three are placed", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied + }) + + // The program changes each on disk. + for path, text := range map[string]string{dunstrc: "font = 99\n", seed: "start = mine\n", + managed: "theme = dark\n" + e.read(t, managed) + "recent = a,b\n"} { + if err := os.WriteFile(e.at(path), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + eventually(t, "the owned file is written back", func() bool { return e.read(t, dunstrc) == "font = 13\n" }) + eventually(t, "prov.applied says it was put back", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied && len(r.Files) == 3 && r.Files[0].Action == "corrected" + }) + time.Sleep(500 * time.Millisecond) + if got := e.read(t, seed); got != "start = mine\n" { + t.Errorf("the seed file is %q: written once, then the program's", got) + } + if got := e.read(t, managed); !strings.Contains(got, "theme = dark\n") || !strings.Contains(got, "recent = a,b\n") || + !strings.Contains(got, "size = 13\n") { + t.Errorf("the managed file is %q: the mesh's block and the program's own lines", got) + } + // The drift pass that finds every file as it should be says nothing. + _, before := heard.last("app") + time.Sleep(600 * time.Millisecond) + if _, after := heard.last("app"); after != before { + t.Errorf("drift passes that moved nothing said %d reports", after-before) + } +} + +func TestNatsAModuleTakenOffTheNodeLosesItsNeedsAndItsReport(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned"}) + b.config(t, "dunst", "font-size", "13") + b.assign(t, "dunst", dunst(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 }) + + b.del(t, AssignmentsBucket, node+".dunst") + eventually(t, "its needs go", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 0 }) + eventually(t, "its report goes", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 0 }) + eventually(t, "prov.applied says it is removed", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusRemoved + }) +} + +func TestNatsANodeComingBackFillsFromTheCurrentValues(t *testing.T) { + b := startBus(t) + const zshrc = "/home/nox/.zshrc" + e := startEngine(t, map[string]string{dunstrc: "owned", zshrc: "owned"}) + b.config(t, "dunst", "font-size", "13") + b.assign(t, "dunst", dunst(t)) + b.config(t, "zsh", "prompt", `"%"`) + tpl, _ := templates(t, map[string]string{"zshrc": "PS1=${config:prompt}\n"}) + b.assign(t, "zsh", Plan{Templates: tpl, Files: []PlannedFile{{ID: "zshrc", Path: zshrc, Template: "zshrc", + OwnerAtRuntime: "owned"}}}) + state := t.TempDir() + _, stop := aClient(t, b, e, state, time.Hour) + eventually(t, "both are filled", func() bool { + return strings.Contains(e.read(t, dunstrc), "Monospace 13") && e.read(t, zshrc) == "PS1=%\n" + }) + + // Away: a value changes and a module leaves, and the artifact store is gone. + stop() + b.config(t, "dunst", "font-size", "17") + b.del(t, AssignmentsBucket, node+".zsh") + + heard := b.listen(t) + aClient(t, b, e, state, time.Hour) + eventually(t, "dunstrc has the value set while it was away", func() bool { + return strings.Contains(e.read(t, dunstrc), "Monospace 17") + }) + eventually(t, "the module that left lost its needs", func() bool { return len(b.keys(t, NeedsBucket, node+".zsh.")) == 0 }) + eventually(t, "and its report", func() bool { + r, _ := heard.last("zsh") + return r.Status == StatusRemoved && len(b.keys(t, AppliedBucket, node+".zsh")) == 0 + }) +} + +// A plan that cannot be acted on, templates that are not the ones named, and a secret the plan does not name each +// fail, with the reason; nothing is written. +func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{dunstrc: "owned", "/etc/app.conf": "owned"}) + aClient(t, b, e, t.TempDir(), time.Hour) + + b.put(t, AssignmentsBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+ + `{"source":"x","digest":"sha256:00"},"files":[],"surprise":1}}`) + p := dunst(t) + p.Templates.Digest = "sha256:" + strings.Repeat("0", 64) + b.assign(t, "forged", p) + tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\n"}) + b.assign(t, "secretive", Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: "/etc/app.conf", + Template: "app.conf", OwnerAtRuntime: "owned"}}}) + + for module, why := range map[string]string{"broken": "surprise", "forged": "and the plan names", + "secretive": "the plan names no secret app-token"} { + eventually(t, module+" fails", func() bool { + r, _ := heard.last(module) + return r.Status == StatusFailed && strings.Contains(r.Reason, why) + }) + } + if _, asked := e.counts("/etc/app.conf"); asked != 0 { + t.Errorf("a file that cannot be filled was written") + } +} + +func TestANeedsKeyHoldsAnyName(t *testing.T) { + for n, want := range map[Need]string{ + {Kind: "config", Name: "font-size"}: "g14.dunst.config.font-size", + {Kind: "seat", Name: "mesh-broker:4222"}: "g14.dunst.seat.mesh-broker=3A4222", + {Kind: "config", Name: "a=b c"}: "g14.dunst.config.a=3Db=20c", + {Kind: "machine", Name: "account-home"}: "g14.dunst.machine.account-home", + } { + if got := NeedKey(node, "dunst", n); got != want { + t.Errorf("%v: %s, want %s", n, got, want) + } + } +} + +// TestNatsResticsFilesFillFromTheBackupPlacement is the backup lines as placement (issue 794): restic's two files +// are the plan's `backup:lines` and `backup:data`, each exactly the text the controller composed, trailing newline +// and all, and a changed plan refills them. +func TestNatsResticsFilesFillFromTheBackupPlacement(t *testing.T) { + const backups, data = "/var/lib/restic/backups.conf", "/var/lib/restic/data.conf" + b := startBus(t) + e := startEngine(t, map[string]string{backups: "owned", data: "owned"}) + tpl, _ := templates(t, map[string]string{ + "backups.conf": "# What the modules on this machine back up, composed by the mesh. Do not edit.\n${backup:lines}", + "data.conf": "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n${backup:data}"}) + plan := func(lines, declared string) Plan { + return Plan{Templates: tpl, Files: []PlannedFile{ + {ID: "declared", Path: backups, Template: "backups.conf", OwnerAtRuntime: "owned"}, + {ID: "data-declared", Path: data, Template: "data.conf", OwnerAtRuntime: "owned"}}, + Placement: map[string]string{"backup:lines": lines, "backup:data": declared}} + } + b.assign(t, "restic", plan("# a\n/srv/a\n# b\n/srv/b\n", "# a\nstate\n")) + aClient(t, b, e, t.TempDir(), time.Hour) + head := "# What the modules on this machine back up, composed by the mesh. Do not edit.\n" + eventually(t, "backups.conf is filled", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n# b\n/srv/b\n" }) + eventually(t, "data.conf is filled", func() bool { + return e.read(t, data) == "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n# a\nstate\n" + }) + b.assign(t, "restic", plan("# a\n/srv/a\n", "# a\nstate\n")) + eventually(t, "a module taken away refills backups.conf", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n" }) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect.go new file mode 100644 index 00000000..b197e070 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect.go @@ -0,0 +1,256 @@ +package main + +import ( + "cmp" + "fmt" + "slices" + "strconv" + "strings" +) + +// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers. A collector, +// a module taking additions from others, names a collection in its template; each contributor ships its item as a +// template of its own. The client of a node fills each contributor's item placed there, from the same owners as a +// file, and records it in mesh-provisioner_collect at `..`; it fills a collector's +// template from every item of the collection recorded for its node, in the contributors' order, each introduced by +// a line naming its contributor. An item goes when its contributor leaves the node, and the collector is filled +// again without it. +// +// **The three old namespaces read as collections.** `${shell::}`, `${environment:}` and +// `${contribution::}`, which the controller fills for a module not handed to the provisioner, name in a +// handed collector's template the collection of the same name: `${shell:zsh:normal}` is `shell:zsh:normal`, the +// collection a contributor's item for zsh's normal slot is recorded in. + +// CollectBucket keeps the items of every collection, `..`. +const CollectBucket = "mesh-provisioner_collect" + +// CollectedKind is the namespace of a collection's placeholder, `${collected:}`. +const CollectedKind = "collected" + +// oldCollectionKinds are the namespaces read as collections of their own name. +var oldCollectionKinds = []string{"shell", "environment", "contribution"} + +// collectionOf is the collection a need names, without its selector, and whether it names one. +func collectionOf(n Need) (string, bool) { + name := "" + if _, is := environmentRenderer(n); is { + return EnvironmentCollection, true + } + switch { + case n.Kind == CollectedKind: + name = n.Name + case slices.Contains(oldCollectionKinds, n.Kind): + name = n.Kind + ":" + n.Name + default: + return "", false + } + collection, _ := splitSelector(name) + return collection, true +} + +// selectorOfNeed is the attributes a need's selector asks for (`${collected:c:bar=top,place=status}`), none for a +// need without one, or why the selector is none (design 57 L3, issue 777). +func selectorOfNeed(n Need) (map[string]string, error) { + name := n.Name + if n.Kind != CollectedKind { + name = n.Kind + ":" + n.Name + } + _, selector := splitSelector(name) + out := map[string]string{} + if selector == "" { + return out, nil + } + for _, pair := range strings.Split(selector, ",") { + k, v, ok := strings.Cut(pair, "=") + switch { + case !ok || !attributeKeyShape.MatchString(k) || !attributeValueShape.MatchString(v): + return nil, fmt.Errorf("%q in %q is not =", pair, selector) + case out[k] != "": + return nil, fmt.Errorf("%q names %s twice", selector, k) + } + out[k] = v + } + return out, nil +} + +// splitSelector is a placeholder's name without its selector, and the selector: `blocks:bar=top,place=left` is +// `blocks` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon +// before the first one (mesh-controller internal/catalogue collections.go). +func splitSelector(name string) (collection, selector string) { + eq := strings.Index(name, "=") + if eq < 0 { + return name, "" + } + colon := strings.LastIndex(name[:eq], ":") + if colon < 0 { + return name, "" + } + return name[:colon], name[colon+1:] +} + +// ItemRecord is one contributor's item, as mesh-provisioner_collect keeps it: filled, never holding a secret. +type ItemRecord struct { + Node string `json:"node"` + Collection string `json:"collection"` + Contributor string `json:"contributor"` + Text string `json:"text"` + // Name tells the item from the contributor's others of the collection; Attributes are what a placeholder + // selecting by them is matched against, `order` among them (design 57 L3, issue 777). + Name string `json:"name,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + // Templates is the digest of the contributor's templates it was filled from. + Templates string `json:"templates,omitempty"` +} + +// itemID is an item's name among its contributor's: the module, and `/` for a named item. It is the +// contributor part of the item's key. +func itemID(module, name string) string { + if name == "" { + return module + } + return module + "/" + name +} + +// splitID is an item's module and name. +func splitID(id string) (module, name string) { + module, name, _ = strings.Cut(id, "/") + return module, name +} + +// itemValue is an item as the client holds it: its filled text and its attributes. +type itemValue struct { + text string + attributes map[string]string +} + +// order is the item's place among the items of one placeholder: its `order` attribute (0 to 99), else 50. +func (v itemValue) order() int { + if n, err := strconv.Atoi(v.attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 { + return n + } + return OrderDefault +} + +// OrderAttribute orders the items of a placeholder, lowest first; OrderDefault is the order of an item with none. +const ( + OrderAttribute = "order" + OrderDefault = 50 +) + +// CollectKey is an item's key, `..`: the collection with every character a key's +// token cannot hold written `=`, so `shell:zsh:normal` is `shell=3Azsh=3Anormal`. +func CollectKey(node, collection, contributor string) string { + return node + "." + keyToken(collection) + "." + contributor +} + +func keyToken(s string) string { + var b strings.Builder + for _, r := range []byte(s) { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteByte(r) + default: + fmt.Fprintf(&b, "=%02X", r) + } + } + return b.String() +} + +// fromKeyToken reads a token keyToken wrote. +func fromKeyToken(s string) (string, bool) { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '=' { + b.WriteByte(s[i]) + continue + } + if i+2 >= len(s) { + return "", false + } + v, err := strconv.ParseUint(s[i+1:i+3], 16, 8) + if err != nil { + return "", false + } + b.WriteByte(byte(v)) + i += 2 + } + return b.String(), true +} + +// itemOfKey is an item key's collection and contributor, for this node; false for a key not in the shape. +func itemOfKey(node, key string) (collection, contributor string, ok bool) { + rest, mine := strings.CutPrefix(key, node+".") + if !mine { + return "", "", false + } + token, contributor, two := strings.Cut(rest, ".") + if !two || token == "" || contributor == "" || strings.Contains(contributor, ".") { + return "", "", false + } + if module, name := splitID(contributor); module == "" || strings.Contains(name, "/") || + (name == "" && strings.Contains(contributor, "/")) { + return "", "", false + } + collection, ok = fromKeyToken(token) + return collection, contributor, ok && collectionShape.MatchString(collection) +} + +// collectionText is a collection's value on this node: every item of it the selector takes (all without one), by +// order, then contributor, then item name, each introduced by a line naming its contributor in the collector's +// comment (none without one), each ending its line. Empty when nothing is contributed: a collection with no item is +// filled, not waited for. +func collectionText(items map[string]itemValue, comment string, where map[string]string) string { + var ids []string + for id, it := range items { + if matches(it, where) { + ids = append(ids, id) + } + } + slices.SortFunc(ids, func(a, b string) int { + if c := cmp.Compare(items[a].order(), items[b].order()); c != 0 { + return c + } + ma, na := splitID(a) + mb, nb := splitID(b) + return cmp.Or(cmp.Compare(ma, mb), cmp.Compare(na, nb)) + }) + var b strings.Builder + for _, id := range ids { + if comment != "" { + module, _ := splitID(id) + fmt.Fprintf(&b, "%s %s\n", comment, module) + } + text := items[id].text + b.WriteString(text) + if !strings.HasSuffix(text, "\n") { + b.WriteString("\n") + } + } + return b.String() +} + +// matches is whether the item carries every attribute the selector asks for. +func matches(it itemValue, where map[string]string) bool { + for k, v := range where { + if got, has := it.attributes[k]; !has || got != v { + return false + } + } + return true +} + +// collectionNamed is the plan's collection of that name. +func (p Plan) collectionNamed(name string) (PlannedCollection, bool) { + for _, c := range p.Collections { + if c.Name == name { + return c, true + } + } + return PlannedCollection{}, false +} + +// collects is whether the plan collects that collection. +func (p Plan) collects(name string) bool { + _, ok := p.collectionNamed(name) + return ok +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_select_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_select_test.go new file mode 100644 index 00000000..3ffe11b6 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_select_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// An item may carry attributes and a module may give several named items to one collection; a placeholder selecting +// by attributes takes only the items that carry them, in the order of the old forms: order, contributor, item name +// (design 57 L3, issue 777). Against a real bus, on the client's own account. + +const barsConf = "/etc/bars.conf" + +// barCollector fills one file from the bottom bar's status blocks, the top bar's, and all of them. +func barCollector(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"bars": "[bottom]\n${collected:blocks:bar=bottom,place=status}" + + "[top]\n${collected:blocks:bar=top,place=status}[all]\n${collected:blocks}"}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: barsConf, Template: "bars", + OwnerAtRuntime: "owned"}}, Collections: []PlannedCollection{{Name: "blocks", Comment: "#"}}} +} + +func blocksOf(t *testing.T, items map[string]PlannedItem, texts map[string]string) Plan { + tpl, _ := templates(t, texts) + p := Plan{Templates: tpl} + for _, name := range sortedKeys(items) { + p.Items = append(p.Items, items[name]) + } + return p +} + +func TestNatsASelectingPlaceholderTakesOnlyTheItemsCarryingItsAttributesInOrder(t *testing.T) { + b := startBus(t) + e := startEngine(t, map[string]string{barsConf: "owned"}) + b.assign(t, "bars", barCollector(t)) + b.assign(t, "power", blocksOf(t, map[string]PlannedItem{ + "battery": {Collection: "blocks", Name: "battery", Template: "battery", + Attributes: map[string]string{"bar": "bottom", "place": "status"}}, + "cpu": {Collection: "blocks", Name: "cpu", Template: "cpu", + Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + "clock": {Collection: "blocks", Name: "clock", Template: "clock", + Attributes: map[string]string{"bar": "top", "place": "status"}}, + }, map[string]string{"battery": "battery\n", "cpu": "cpu\n", "clock": "clock\n"})) + b.assign(t, "another", blocksOf(t, map[string]PlannedItem{ + "disk": {Collection: "blocks", Template: "disk", Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + }, map[string]string{"disk": "disk\n"})) + aClient(t, b, e, t.TempDir(), time.Hour) + + want := "[bottom]\n# another\ndisk\n# power\ncpu\n# power\nbattery\n" + + "[top]\n# power\nclock\n" + + "[all]\n# another\ndisk\n# power\ncpu\n# power\nbattery\n# power\nclock\n" + eventually(t, "the file holds each placeholder's items", func() bool { return e.read(t, barsConf) == want }) + eventually(t, "the named items are recorded under their module and name", func() bool { + got := b.keys(t, CollectBucket, node+".blocks.") + return len(got) == 4 && got[0] == "g14.blocks.another" && got[1] == "g14.blocks.power/battery" && + got[2] == "g14.blocks.power/clock" && got[3] == "g14.blocks.power/cpu" + }) + + // An item no longer contributed goes, and only that one. + b.assign(t, "power", blocksOf(t, map[string]PlannedItem{ + "clock": {Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}}, + }, map[string]string{"clock": "clock\n"})) + eventually(t, "the items of the module's others go", func() bool { + return strings.Contains(e.read(t, barsConf), "[bottom]\n# another\ndisk\n[top]") + }) + if got := b.keys(t, CollectBucket, node+".blocks."); len(got) != 2 { + t.Errorf("kept %v", got) + } +} + +func TestASelectorIsReadFromAPlaceholderOfAnyNamespace(t *testing.T) { + for n, want := range map[Need]string{ + {Kind: "collected", Name: "blocks:bar=top,place=status"}: "blocks", + {Kind: "collected", Name: "blocks"}: "blocks", + {Kind: "collected", Name: "a:b:k=v"}: "a:b", + {Kind: "contribution", Name: "node-bar:block:bar=top,place=left"}: "contribution:node-bar:block", + } { + if got, ok := collectionOf(n); !ok || got != want { + t.Errorf("%s is the collection %q (%v), want %q", n, got, ok, want) + } + } + if where, err := selectorOfNeed(Need{Kind: "collected", Name: "blocks:bar=top,place=status"}); err != nil || + len(where) != 2 || where["bar"] != "top" || where["place"] != "status" { + t.Errorf("the selector is %v (%v)", where, err) + } + for _, bad := range []string{"blocks:bar=top,bar=top", "blocks:bar=", "blocks:bar=top,"} { + if _, err := selectorOfNeed(Need{Kind: "collected", Name: bad}); err == nil { + t.Errorf("%s was read as a selector", bad) + } + } +} + +func TestAnItemsKeyHoldsItsName(t *testing.T) { + key := CollectKey(node, "blocks", itemID("power", "battery")) + if key != "g14.blocks.power/battery" { + t.Fatal(key) + } + if c, id, ok := itemOfKey(node, key); !ok || c != "blocks" || id != "power/battery" { + t.Errorf("reads back as %q %q %v", c, id, ok) + } + for _, bad := range []string{"g14.blocks.power/", "g14.blocks./x", "g14.blocks.a/b/c"} { + if _, _, ok := itemOfKey(node, bad); ok { + t.Errorf("%s read as an item's key", bad) + } + } + p := Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)}, + Items: []PlannedItem{{Collection: "a", Name: "n", Template: "i"}, {Collection: "a", Name: "n", Template: "j"}, + {Collection: "a", Name: "Big", Template: "k", Attributes: map[string]string{"order": "100", "Bar": "x"}}}} + got := strings.Join(p.problems(), "\n") + for _, want := range []string{"item a/n is named twice", `"Big" is not a name`, "0 to 99", "attribute Bar"} { + if !strings.Contains(got, want) { + t.Errorf("the problems %q do not say %q", got, want) + } + } +} + +func TestAnItemOfAPlanReadsItsNameAndAttributesAndRefusesAnUnknownField(t *testing.T) { + plan := func(item string) string { + return `{"node":"g14","module":"power","provisioning":{"templates":{"source":"x","digest":"sha256:` + + strings.Repeat("0", 64) + `"},"files":[],"items":[` + item + `]}}` + } + _, p, err := planOf("g14", "g14.power", []byte(plan( + `{"collection":"blocks","name":"battery","attributes":{"bar":"top"},"template":"battery"}`))) + if err != nil || len(p.Items) != 1 || p.Items[0].Name != "battery" || p.Items[0].Attributes["bar"] != "top" { + t.Fatalf("read %+v (%v)", p.Items, err) + } + if _, _, err := planOf("g14", "g14.power", []byte(plan( + `{"collection":"blocks","template":"battery","colour":"red"}`))); err == nil || !strings.Contains(err.Error(), "colour") { + t.Errorf("an unknown item field was accepted: %v", err) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go new file mode 100644 index 00000000..f58a7ca3 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +// A collection's test of novox/hq ADR 0349 ("How it is checked", design 57 L1, issue 684): zsh's file holds every +// placed contributor's item and loses one whose contributor leaves. Against a real bus, on the client's own account. + +const zshrc = "/home/nox/.zshrc" + +// zshCollector is zsh as a handed collector: its rc names its normal slot the old way, read as the collection +// shell:zsh:normal, and a collection of the new form. +func zshCollector(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"zshrc": "# zsh\n${shell:zsh:normal}# then\n${collected:zsh-widgets}# end\n"}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "rc", Path: zshrc, Template: "zshrc", + OwnerAtRuntime: "owned"}}, + Collections: []PlannedCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "zsh-widgets"}}} +} + +// contributor is a module whose only part on the node is its item of collection, from its own template. +func contributor(t *testing.T, collection, text string) Plan { + tpl, _ := templates(t, map[string]string{"item": text}) + return Plan{Templates: tpl, Items: []PlannedItem{{Collection: collection, Template: "item"}}, + Placement: map[string]string{"machine:account-home": "/home/nox"}} +} + +func TestNatsZshsFileHoldsEveryPlacedContributorsItemAndLosesOneWhoseContributorLeaves(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{zshrc: "owned", "/etc/fzf.conf": "owned"}) + b.assign(t, "zsh", zshCollector(t)) + b.assign(t, "powerlevel10k", contributor(t, "shell:zsh:normal", + "source ${machine:account-home}/.p10k.zsh\n")) + b.assign(t, "mesh-cli", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli")) + b.config(t, "fzf", "binding", `"^T"`) + fzf := contributor(t, "zsh-widgets", "bindkey ${config:binding} fzf-file-widget\n") + b.assign(t, "fzf", fzf) + aClient(t, b, e, t.TempDir(), time.Hour) + + every := "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# powerlevel10k\nsource /home/nox/.p10k.zsh\n# then\n" + + "bindkey ^T fzf-file-widget\n# end\n" + eventually(t, "zshrc holds every contributor's item", func() bool { return e.read(t, zshrc) == every }) + // Each item recorded under the node, the collection and its contributor; filled, the contributor's report says so. + eventually(t, "the items are recorded", func() bool { + got := b.keys(t, CollectBucket, node+".") + return len(got) == 3 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli" && + got[1] == "g14.shell=3Azsh=3Anormal.powerlevel10k" && got[2] == "g14.zsh-widgets.fzf" + }) + kept, err := b.kv[CollectBucket].Get(context.Background(), "g14.shell=3Azsh=3Anormal.powerlevel10k") + if err != nil { + t.Fatal(err) + } + var it ItemRecord + if err := json.Unmarshal(kept.Value(), &it); err != nil || it.Text != "source /home/nox/.p10k.zsh\n" || + it.Collection != "shell:zsh:normal" || it.Contributor != "powerlevel10k" { + t.Errorf("the item kept is %s (%v)", kept.Value(), err) + } + eventually(t, "powerlevel10k's report says its item", func() bool { + r, _ := heard.last("powerlevel10k") + return r.Status == StatusApplied && len(r.Items) == 1 && r.Items[0] == "shell:zsh:normal" + }) + // zsh records the collections it reads as its needs, like any value. + eventually(t, "zsh's needs are recorded", func() bool { + got := b.keys(t, NeedsBucket, node+".zsh.") + return len(got) == 2 && got[0] == "g14.zsh.collected.zsh-widgets" && got[1] == "g14.zsh.shell.zsh=3Anormal" + }) + + // A contributor's own value changes: its item, and so zshrc, follow. + b.config(t, "fzf", "binding", `"^F"`) + eventually(t, "zshrc follows the item", func() bool { return strings.Contains(e.read(t, zshrc), "bindkey ^F") }) + + // powerlevel10k leaves the node: its item goes, and zshrc without it. + b.del(t, AssignmentsBucket, node+".powerlevel10k") + without := "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\nbindkey ^F fzf-file-widget\n# end\n" + eventually(t, "zshrc loses the item of the contributor that left", func() bool { return e.read(t, zshrc) == without }) + if got := b.keys(t, CollectBucket, node+".shell=3Azsh=3Anormal.powerlevel10k"); len(got) != 0 { + t.Errorf("the item of a contributor that left is kept: %v", got) + } + // And a contributor that stops contributing, though it stays: the same. + b.assign(t, "fzf", Plan{Templates: fzf.Templates, Files: []PlannedFile{{ID: "conf", Path: "/etc/fzf.conf", + Template: "item", OwnerAtRuntime: "owned"}}, Placement: fzf.Placement}) + eventually(t, "zshrc loses an item no longer contributed", func() bool { + return e.read(t, zshrc) == "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\n# end\n" + }) +} + +func TestNatsACollectorStartingWithItsNodeHoldsTheItemsAlreadyThereAndOnesRecordedWhileAway(t *testing.T) { + b := startBus(t) + e := startEngine(t, map[string]string{zshrc: "owned"}) + b.assign(t, "zsh", zshCollector(t)) + b.assign(t, "mesh-cli", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli\n")) + state := t.TempDir() + _, stop := aClient(t, b, e, state, time.Hour) + eventually(t, "zshrc holds mesh-cli's item", func() bool { return strings.Contains(e.read(t, zshrc), "alias nox") }) + + stop() + b.del(t, AssignmentsBucket, node+".mesh-cli") + b.assign(t, "zsh-autosuggestions", contributor(t, "shell:zsh:normal", "source suggestions.zsh\n")) + aClient(t, b, e, state, time.Hour) + want := "# zsh\n# zsh-autosuggestions\nsource suggestions.zsh\n# then\n# end\n" + eventually(t, "zshrc is filled from the items now", func() bool { return e.read(t, zshrc) == want }) + eventually(t, "the item of the module that left while away is removed", func() bool { + return len(b.keys(t, CollectBucket, node+".shell=3Azsh=3Anormal.mesh-cli")) == 0 + }) +} + +// A collection the plan does not name, and an item naming a secret, fail with the reason; an item is never +// recorded with a secret in it. +func TestNatsACollectionNotPlannedAndAnItemNamingASecretFail(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{zshrc: "owned"}) + p := zshCollector(t) + p.Collections = p.Collections[:1] + b.assign(t, "zsh", p) + b.assign(t, "leaky", contributor(t, "shell:zsh:normal", "export TOKEN=${secret:token}\n")) + aClient(t, b, e, t.TempDir(), time.Hour) + for module, why := range map[string]string{"zsh": "the plan names no collection zsh-widgets", + "leaky": "where no secret is"} { + eventually(t, module+" fails", func() bool { + r, _ := heard.last(module) + return r.Status == StatusFailed && strings.Contains(r.Reason, why) + }) + } + if got := b.keys(t, CollectBucket, node+"."); len(got) != 0 { + t.Errorf("recorded %v", got) + } + if _, asked := e.counts(zshrc); asked != 0 { + t.Errorf("a collector naming a collection its plan does not was written") + } +} + +func TestAnItemsKeyHoldsAnyCollectionAndReadsBack(t *testing.T) { + for collection, want := range map[string]string{ + "zsh-widgets": "g14.zsh-widgets.fzf", + "shell:zsh:normal": "g14.shell=3Azsh=3Anormal.fzf", + "contribution:node-hotkeys:trigger": "g14.contribution=3Anode-hotkeys=3Atrigger.fzf", + } { + key := CollectKey(node, collection, "fzf") + if key != want { + t.Errorf("%s: %s, want %s", collection, key, want) + } + c, who, ok := itemOfKey(node, key) + if !ok || c != collection || who != "fzf" { + t.Errorf("%s reads back as %q %q %v", key, c, who, ok) + } + } + for _, bad := range []string{"g14.x", "ace.zsh-widgets.fzf", "g14.=ZZ.fzf", "g14.a.b.c", "g14.Upper.fzf"} { + if _, _, ok := itemOfKey(node, bad); ok { + t.Errorf("%s read as an item's key", bad) + } + } +} + +func TestAPlansItemsAndCollectionsAreJudged(t *testing.T) { + p := Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)}, + Items: []PlannedItem{{Collection: "a", Template: "item"}, {Collection: "a", Template: "../x"}, + {Collection: "Bad", Template: "y"}}, + Collections: []PlannedCollection{{Name: "c", Comment: "#\n"}, {Name: "c"}}} + got := strings.Join(p.problems(), "\n") + for _, want := range []string{"item a is named twice", `template "../x"`, `item "Bad" is not`, + "collection c: its comment holds a line break", "collection c is named twice"} { + if !strings.Contains(got, want) { + t.Errorf("the problems %q do not say %q", got, want) + } + } + // A plan contributing an item and filling no file is one the client acts on. + if wrong := (Plan{Templates: p.Templates, Items: p.Items[:1]}).problems(); len(wrong) != 0 { + t.Errorf("a contributor's plan: %v", wrong) + } + if wrong := (Plan{Templates: p.Templates}).problems(); len(wrong) != 1 { + t.Errorf("a plan doing nothing: %v", wrong) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go new file mode 100644 index 00000000..61e29d13 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go @@ -0,0 +1,257 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" +) + +// A provider's consumers' credentials, and a consumer's values from its provider (novox/hq ADR 0349 clause 12, ADR +// 0373, issue 679). +// +// **`${consumers:}`: each consumer bound to this provider, with its credential.** A provider in the tool +// runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its machine's client fills its file. A +// consumer binds the provider where its plan, on any machine, names this machine and this module as the provider +// of the provision (issue 760); so the client reads every machine's plans (the whole assignments mirror, which +// carries no secret's value) for who binds, and asks the vault for each consumer's own credential, +// `pair////[/]`, which the vault hands the provider's machine while the +// provider is handed there (mesh-vault pair.go). The file lists one entry per consumer, in the order of the +// assignments' keys and, within one plan, of its provisions entries: a JSON list, each naming the consumer and its +// credential's name and carrying its value, which the provider's helper reads (mesh-sdk go/provider ConsumersFile). +// A consumer whose credential the vault does not hold yet, or will not hand, is left out and said, so the others are +// served; the provider's helper waits for it. An entry with a name is a credential the provider's software issued +// per holder (issue 757), which the provider holds already: not listed; nor is a bind whose offer names another +// credential form than a login per consumer (issue 768): a shared value is the provider's own secret, and an issued +// or given one is not the vault's to make. The file is the provider's alone: the +// catalogue holds it to mode 0600 and owned (mesh-controller internal/catalogue consumers.go). It is filled again +// when a consumer binds or unbinds, and when the vault says one of the credentials changed. +// +// **`${bound::}`: the consumer's entry in its provider's bucket.** The provider's helper writes one +// entry per consumer to its own bucket, `_bound`, keyed `..[.]` +// (mesh-sdk go/provider, issue 678), as the registry says. The client watches its own machine's keys of the bucket +// of each provider its plans name, and fills a key from the entry: `as`, `user`, or one of its served values. An +// entry the provider refused fails the module with the provider's reason; one not written yet is waited for. + +// ConsumersKind is the namespace of a provider's consumers' credentials, `${consumers:}`. +const ConsumersKind = "consumers" + +// BoundKind is the namespace of a consumer's value from its provider, `${bound::}`. +const BoundKind = "bound" + +// boundSuffix ends a provider's bucket of entries: `_bound`. +const boundSuffix = "_bound" + +// bindsOf is as much of any machine's assignment as the client reads for who binds a provider: lenient, since +// another machine's plan is that machine's client's to act on. +type bindsOf struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []PlannedProvision `json:"provisions"` + } `json:"provisioning"` +} + +// binding is one assignment's binds: its machine and module, and its plan's provisions in order. +type binding struct { + node, module string + provisions []PlannedProvision +} + +// takeBinds folds one assignment, of any machine, into who binds what; it answers whether that changed. +func takeBinds(s *state, key string, value []byte, gone bool) bool { + var was *binding + if b, ok := s.binds[key]; ok { + was = &b + } + if gone { + delete(s.binds, key) + return was != nil + } + var a bindsOf + if err := json.Unmarshal(value, &a); err != nil || a.Node+"."+a.Module != key { + logf("[provisioner-client] the assignment %s is not the controller's, so whom it binds is not read", key) + delete(s.binds, key) + return was != nil + } + b := binding{node: a.Node, module: a.Module} + if a.Provisioning != nil { + b.provisions = a.Provisioning.Provisions + } + s.binds[key] = b + return was == nil || was.node != b.node || was.module != b.module || !reflect.DeepEqual(was.provisions, b.provisions) +} + +// PairName is one consumer's own credential as the vault names it (mesh-vault pair.go, mesh-sdk go/provider +// PairName). +func PairName(provider, provision, node, module, local string) string { + n := "pair/" + provider + "/" + provision + "/" + node + "/" + module + if local != "" { + n += "/" + local + } + return n +} + +// consumerListed is one entry of a provider's consumers' file (mesh-sdk go/provider ConsumerCredential). +type consumerListed struct { + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + Credential string `json:"credential"` + Value string `json:"value"` +} + +// consumersOf is the file text of a provider's consumers for one provision, or why it cannot be filled. +func (c *Client) consumersOf(ctx context.Context, s *state, module string, n Need) (value, wait, fail string) { + s.providing[module] = true + list := []consumerListed{} + var left []string + for _, key := range sortedKeys(s.binds) { + b := s.binds[key] + for _, pr := range b.provisions { + if pr.issuedForHolder() || pr.Provider != c.Node || pr.ProviderModule != module || pr.Provision != n.Name || + (pr.CredentialForm != "" && pr.CredentialForm != "pair") { + continue + } + name := PairName(c.Node, n.Name, b.node, b.module, pr.Local) + v, w, f := c.vaultValue(ctx, s, n, name) + switch { + case f != "": + left = append(left, f) + continue + case w != "": + left = append(left, w) + continue + } + list = append(list, consumerListed{Consumer: b.node, Module: b.module, Local: pr.Local, Credential: name, + Value: v}) + } + } + said := strings.Join(left, "; ") + if s.leftOut[module+" "+n.Name] != said { + s.leftOut[module+" "+n.Name] = said + if said != "" { + logf("[provisioner-client] %s: consumers left out of %s until the vault hands their credential: %s", + module, n, said) + } + } + text, err := json.Marshal(list) + if err != nil { + return "", "", fmt.Sprintf("%s: %v", n, err) + } + return string(text), "", "" +} + +// boundEntry is as much of a provider's entry as a consumer's file reads (mesh-sdk go/provider Entry). +type boundEntry struct { + As string `json:"as"` + User string `json:"user"` + Values map[string]any `json:"values"` + Refused string `json:"refused"` +} + +// boundOf is a consumer's value from its provider's entry, or what it waits for, or why it cannot be filled. +func (c *Client) boundOf(s *state, module string, p Plan, n Need, answer Answer) (value, wait, fail string) { + provision, key, ok := strings.Cut(n.Name, ":") + if !ok || provision == "" || key == "" { + return "", "", n.String() + ": a bound value is ${bound::}" + } + key, fallback, defaulted := strings.Cut(key, "|default:") + var pr *PlannedProvision + for i := range p.Provisions { + q := p.Provisions[i] + if q.issuedForHolder() || q.Provision != provision { + continue + } + if pr == nil || q.Local == "" { + pr = &p.Provisions[i] + } + } + switch { + case pr == nil && defaulted: + return fallback, "", "" + case pr == nil: + return "", "", fmt.Sprintf("%s: the plan names no provider of %s for %s here", n, provision, module) + case pr.ProviderModule == "": + return "", "", fmt.Sprintf("%s: the plan names no provider module of %s", n, provision) + } + bucket := strings.Replace(answer.Bucket, "", pr.ProviderModule, 1) + if bucket != pr.ProviderModule+boundSuffix { + return "", "", fmt.Sprintf("%s: the registry answers bound from %q, which this client does not read", n, + answer.Bucket) + } + if s.boundUsers[bucket] == nil { + s.boundUsers[bucket] = map[string]bool{} + } + s.boundUsers[bucket][module] = true + if c.watchBound == nil { + return "", "", n.String() + ": this client watches no provider's bucket" + } + if err := c.watchBound(bucket); err != nil { + return "", fmt.Sprintf("%s (the provider's bucket %s cannot be watched yet: %v)", n, bucket, err), "" + } + if !s.boundReady[bucket] { + return "", fmt.Sprintf("%s (the entries of %s are not read yet)", n, bucket), "" + } + entryKey := c.Node + "." + module + "." + provision + if pr.Local != "" { + entryKey += "." + pr.Local + } + raw, ok := s.bound[bucket][entryKey] + if !ok { + return "", fmt.Sprintf("%s (%s on %s has not answered %s on %s yet)", n, pr.ProviderModule, pr.Provider, + module, c.Node), "" + } + var e boundEntry + if err := json.Unmarshal(raw, &e); err != nil { + return "", "", fmt.Sprintf("%s: the entry %s of %s is not the provider's", n, entryKey, bucket) + } + if e.Refused != "" { + return "", "", fmt.Sprintf("%s: %s on %s refused it: %s", n, pr.ProviderModule, pr.Provider, e.Refused) + } + switch key { + case "as": + if e.As != "" { + return e.As, "", "" + } + case "user": + if e.User != "" { + return e.User, "", "" + } + } + if v, ok := e.Values[key]; ok { + return plainly(v), "", "" + } + have := []string{} + if e.As != "" { + have = append(have, "as") + } + if e.User != "" { + have = append(have, "user") + } + have = append(have, sortedKeys(e.Values)...) + return "", "", fmt.Sprintf("%s: %s on %s serves %s no %q; it serves %s", n, pr.ProviderModule, pr.Provider, module, + key, strings.Join(have, ", ")) +} + +// plainly is a served value as a file takes it: a string as it is, a whole number without a fraction, anything else +// as its JSON (mesh-controller internal/catalogue plainly). +func plainly(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + return strconv.FormatBool(x) + case nil: + return "" + } + raw, _ := json.Marshal(v) + return string(raw) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go new file mode 100644 index 00000000..469e4396 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// A provider's consumers' credentials, filled by its machine's client (novox/hq ADR 0373, issue 679), against a real +// bus whose accounts are the client's as mesh-controller composes them: a provider template with +// ${consumers:} is filled with one entry per bound consumer, in the plan's provisions order, each carrying +// the credential the vault holds for that pair; a consumer that binds or unbinds, or a credential the vault remakes, +// changes the file within one watch; no value crosses the bus in the clear. And a consumer's ${bound:…} is filled from +// its provider's entry (mesh-sdk go/provider, issue 678). + +const consumersFile = "/var/lib/postgres/consumers.json" + +// registryOf679 writes the registry's two answers this issue adds, as mesh-provisioner writes them. +func registryOf679(t *testing.T, b *aBus) { + t.Helper() + b.put(t, RegistryBucket, "consumers", `{"kind":"consumers","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`) + b.put(t, RegistryBucket, "bound", `{"kind":"bound","domain":"provider","from":"bucket",`+ + `"bucket":"_bound","key":"..[.]"}`) +} + +// bind writes another machine's module's assignment, whose plan binds it to providers, as the controller does. +func (b *aBus) bind(t *testing.T, at, module string, provisions ...PlannedProvision) { + t.Helper() + ps, err := json.Marshal(provisions) + if err != nil { + t.Fatal(err) + } + b.put(t, AssignmentsBucket, at+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ + `"provisioning":{"templates":{"source":"x","digest":"sha256:%s"},"files":[{"id":"c","path":"/etc/c",`+ + `"template":"c","owner-at-runtime":"owned"}],"provisions":%s}}`, at, module, strings.Repeat("0", 64), ps)) +} + +func postgresPlan(t *testing.T, text string) Plan { + tpl, _ := templates(t, map[string]string{"consumers.json": text}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "consumers", Path: consumersFile, Template: "consumers.json", + OwnerAtRuntime: "owned"}}} +} + +func fromHere(local string) PlannedProvision { + return PlannedProvision{Provision: "postgres-database", Local: local, Provider: node, ProviderModule: "postgres"} +} + +func pairOf(at, module, local string) string { + n := "pair/" + node + "/postgres-database/" + at + "/" + module + if local != "" { + n += "/" + local + } + return n +} + +type listed struct { + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + Credential string `json:"credential"` + Value string `json:"value"` +} + +func consumersIn(t *testing.T, e *engine) []listed { + t.Helper() + var out []listed + if err := json.Unmarshal([]byte(e.read(t, consumersFile)), &out); err != nil { + return nil + } + return out +} + +func sameList(have []listed, want ...listed) bool { + if len(have) != len(want) { + return false + } + for i := range want { + if have[i] != want[i] { + return false + } + } + return true +} + +func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake(t *testing.T) { + b := startBus(t) + registryOf679(t, b) + heard := b.listen(t) + wire := sniff(t, b) + vault := startVault(t, b) + vault.keep(pairOf("ace", "letta", ""), "letta-pw-never-on-the-bus", false) + vault.keep(pairOf("shanks", "gitea", "cache"), "gitea-pw-never-on-the-bus", false) + // Bound to this provider: ace's letta, and shanks's gitea under a local name. Not: a provision of another + // provider, another provision, and a credential its provider's software issued per holder (issue 757). + b.bind(t, "ace", "letta", fromHere(""), + PlannedProvision{Provision: "postgres-database", Local: "old", Provider: "ace", ProviderModule: "postgres"}) + b.bind(t, "shanks", "gitea", PlannedProvision{Provision: "redis", Provider: node, ProviderModule: "postgres"}, + fromHere("cache"), + PlannedProvision{Name: "token", Provision: "forge-read", Provider: node, ProviderModule: "postgres"}) + // Nor a bind whose offer names another credential form than a login per consumer (issue 768). + b.bind(t, "ace", "shared", PlannedProvision{Provision: "postgres-database", Provider: node, + ProviderModule: "postgres", CredentialForm: "shared"}) + e := startEngine(t, map[string]string{consumersFile: "owned"}) + b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}")) + aClient(t, b, e, t.TempDir(), time.Hour) + + letta := listed{Consumer: "ace", Module: "letta", Credential: pairOf("ace", "letta", ""), + Value: "letta-pw-never-on-the-bus"} + gitea := listed{Consumer: "shanks", Module: "gitea", Local: "cache", Credential: pairOf("shanks", "gitea", "cache"), + Value: "gitea-pw-never-on-the-bus"} + eventually(t, "the file lists each bound consumer with its credential, in the plans' order", func() bool { + return sameList(consumersIn(t, e), letta, gitea) + }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("postgres") + return r.Status == StatusApplied + }) + + // A consumer binds, on this machine: listed once the vault holds its credential; until then left out, and the + // others kept. + b.bind(t, node, "photos", fromHere("")) + time.Sleep(300 * time.Millisecond) + if !sameList(consumersIn(t, e), letta, gitea) { + t.Fatalf("a consumer whose credential the vault does not hold yet changed the file: %+v", consumersIn(t, e)) + } + vault.keep(pairOf(node, "photos", ""), "photos-pw-never-on-the-bus", true) + photos := listed{Consumer: node, Module: "photos", Credential: pairOf(node, "photos", ""), + Value: "photos-pw-never-on-the-bus"} + eventually(t, "a consumer that binds is listed when the vault holds its credential, in its assignment's order", + func() bool { return sameList(consumersIn(t, e), letta, photos, gitea) }) + // The vault remakes a credential. + vault.keep(pairOf("ace", "letta", ""), "letta-pw-remade-never-on-the-bus", true) + letta.Value = "letta-pw-remade-never-on-the-bus" + eventually(t, "a credential the vault remakes reaches the file", func() bool { + return sameList(consumersIn(t, e), letta, photos, gitea) + }) + // A consumer unbinds. + b.del(t, AssignmentsBucket, "shanks.gitea") + eventually(t, "a consumer that unbinds leaves the file", func() bool { + return sameList(consumersIn(t, e), letta, photos) + }) + // Another provider's consumer's credential changing is no reason to ask. + before := vault.times(pairOf("ace", "letta", "")) + vault.keep("pair/ace/postgres-database/ace/letta/old", "elsewhere", true) + time.Sleep(300 * time.Millisecond) + if vault.times("pair/ace/postgres-database/ace/letta/old") != 0 || vault.times(pairOf("ace", "letta", "")) != before { + t.Error("a change of another provider's consumer made the client ask") + } + + if wire.heard() == 0 { + t.Fatal("the sniffer heard nothing: it proves nothing") + } + for _, value := range []string{"letta-pw", "gitea-pw", "photos-pw"} { + if said := wire.carrying(value); len(said) > 0 { + t.Errorf("a consumer's credential crossed the bus in the clear: %q", said) + } + } + // The need is recorded as any other, by name: never a value. + if keys := b.keys(t, NeedsBucket, node+".postgres."); len(keys) != 1 || + keys[0] != node+".postgres.consumers.postgres-database" { + t.Errorf("the provider's needs are %v", keys) + } +} + +// A provider with no consumer bound is filled with an empty list, never left waiting. +func TestNatsAProviderWithNoConsumerIsFilledWithAnEmptyList(t *testing.T) { + b := startBus(t) + registryOf679(t, b) + startVault(t, b) + e := startEngine(t, map[string]string{consumersFile: "owned"}) + b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}")) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "the file holds an empty list", func() bool { return e.read(t, consumersFile) == "[]" }) +} + +// An item is kept in a bucket, where no secret is: an item naming its consumers fails. +func TestAnItemNamingConsumersIsRefused(t *testing.T) { + p := Plan{Items: []PlannedItem{{Collection: "c", Template: "item"}}} + if _, why := itemTargets(p, map[string]string{"item": "${consumers:postgres-database}"}); !strings.Contains(why, + "no secret") { + t.Fatalf("an item naming consumers: %q", why) + } +} + +const appBound = "/etc/app/database.env" + +func appPlan(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"database.env": "HOST=${bound:postgres-database:host}\n" + + "PORT=${bound:postgres-database:port}\nUSER=${bound:postgres-database:user}\n"}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "db", Path: appBound, Template: "database.env", + OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}} +} + +func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) { + b := startBus(t) + registryOf679(t, b) + heard := b.listen(t) + e := startEngine(t, map[string]string{appBound: "owned"}) + b.assign(t, "app", appPlan(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + // No bucket of the provider's yet: waited for, and said. + eventually(t, "the consumer waits for its provider's bucket", func() bool { + r, _ := heard.last("app") + return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres_bound") + }) + kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"}) + if err != nil { + t.Fatal(err) + } + b.kv["postgres_bound"] = kv + // The provider has not answered it yet: waited for. + b.put(t, "postgres_bound", "ace.app.postgres-database", `{"values":{"host":"elsewhere"}}`) + b.assign(t, "app", appPlan(t)) // a change of its own, so it looks again before the drift pass + eventually(t, "the consumer waits for its own entry", func() bool { + r, _ := heard.last("app") + return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres on novox") + }) + b.put(t, "postgres_bound", node+".app.postgres-database", `{"provider":"novox","provider-module":"postgres",`+ + `"provision":"postgres-database","consumer":"g14","module":"app","as":"mesh_g14_app","user":"mesh_g14_app",`+ + `"values":{"host":"novox.mesh","port":5432},"credential":"pair/novox/postgres-database/g14/app"}`) + eventually(t, "the file is filled from its provider's entry", func() bool { + return e.read(t, appBound) == "HOST=novox.mesh\nPORT=5432\nUSER=mesh_g14_app\n" + }) + b.put(t, "postgres_bound", node+".app.postgres-database", `{"user":"mesh_g14_app",`+ + `"values":{"host":"novox.lan","port":5432}}`) + eventually(t, "a changed entry refills the file", func() bool { + return e.read(t, appBound) == "HOST=novox.lan\nPORT=5432\nUSER=mesh_g14_app\n" + }) + b.put(t, "postgres_bound", node+".app.postgres-database", `{"refused":"app on g14 keeps several holders"}`) + eventually(t, "an entry the provider refused fails the consumer with its reason", func() bool { + r, _ := heard.last("app") + return r.Status == StatusFailed && strings.Contains(r.Reason, "keeps several holders") + }) +} + +// A consumer whose plan names its login's file (issue 779) is still a bind of its provider: it is listed, where an +// entry naming a value its provider's software issued per holder (issue 757) is not. +func TestNatsAProviderListsAConsumerWhoseEntryNamesItsLoginFile(t *testing.T) { + b := startBus(t) + registryOf679(t, b) + vault := startVault(t, b) + vault.keep(pairOf("ace", "nextcloud", ""), "nc-pw-never-on-the-bus", false) + vault.keep(pairOf("ace", "forge", ""), "forge-pw", false) + b.bind(t, "ace", "nextcloud", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node, + ProviderModule: "postgres", CredentialForm: "pair", Path: "/s/database.secret"}) + b.bind(t, "ace", "forge", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node, + ProviderModule: "postgres", CredentialForm: "issued", Path: "/s/forge.secret"}) + e := startEngine(t, map[string]string{consumersFile: "owned"}) + b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}")) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "the consumer with a login file is listed, the per-holder one is not", func() bool { + return sameList(consumersIn(t, e), listed{Consumer: "ace", Module: "nextcloud", + Credential: pairOf("ace", "nextcloud", ""), Value: "nc-pw-never-on-the-bus"}) + }) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment.go new file mode 100644 index 00000000..2dae079a --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment.go @@ -0,0 +1,234 @@ +package main + +import ( + "cmp" + "fmt" + "regexp" + "slices" + "strings" +) + +// The session environment as a collection (novox/hq ADR 0203, ADR 0349 clause 13, design 57 L3, issue 795). One +// collection, `environment`, whose items are typed by attributes: an item is a variable (`kind=variable`, +// `name=`) or a PATH entry (`kind=path`, `at=start|end`), and its template's text is the variable's value or +// the entry. The collector names the rendering, not a selector: `${collected:environment:posix}` and +// `${collected:environment:systemd}` (and the old `${environment:posix|systemd}`) are the collection `environment` +// written for a POSIX shell or for environment.d. Both renderings are the ones mesh-controller internal/catalogue +// environment_into.go writes for a collector not handed, byte for byte (its golden files are this client's). + +// Environment collection and renderers. +const ( + EnvironmentCollection = "environment" + EnvironmentPOSIX = "posix" + EnvironmentSystemd = "systemd" +) + +// environmentName is an environment variable's name, which only this collection's `name` attribute may be. +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// attributeFits is whether an item's attribute is of the shape the collection allows. +func attributeFits(collection, k, v string) bool { + if collection == EnvironmentCollection && k == "name" { + return environmentName.MatchString(v) + } + return attributeKeyShape.MatchString(k) && attributeValueShape.MatchString(v) +} + +// environmentRenderer is the rendering a need names, when it is the environment's: `${collected:environment:posix}` +// or `${environment:posix}`. +func environmentRenderer(n Need) (string, bool) { + name := n.Name + switch { + case n.Kind == "environment": + case n.Kind == CollectedKind: + rest, is := strings.CutPrefix(name, EnvironmentCollection+":") + if !is { + return "", false + } + name = rest + default: + return "", false + } + return name, name == EnvironmentPOSIX || name == EnvironmentSystemd +} + +type envVariable struct{ module, name, value string } + +type envEntry struct { + module, entry string + order int + item string +} + +// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in both formats +// (mesh-controller internal/catalogue environment_into.go). +func literalProblem(v string) string { + switch { + case strings.ContainsAny(v, `'"`): + return "holds a quote" + case strings.Contains(v, `\`): + return "holds a backslash" + case strings.ContainsAny(v, "\n\r"): + return "holds a line break" + case strings.ContainsRune(v, 0): + return "holds a NUL" + case strings.Contains(v, "$"): + return "holds a $, which the two readers expand differently" + } + return "" +} + +// environmentText is the collection's items written for one renderer: modules in name order, each module's variables +// sorted by name under a line naming it, then PATH's entries, each once where the first puts it, as the old form +// wrote them. An empty collection is empty text. +func environmentText(items map[string]itemValue, renderer string) (string, error) { + type owned struct { + id string + itemValue + } + var all []owned + for id, it := range items { + all = append(all, owned{id, it}) + } + slices.SortFunc(all, func(a, b owned) int { + ma, na := splitID(a.id) + mb, nb := splitID(b.id) + return cmp.Or(cmp.Compare(ma, mb), cmp.Compare(na, nb)) + }) + var variables []envVariable + var start, end []envEntry + setter := map[string]string{} + placed := map[string]bool{} + // Items of one module in the order the old form declared them: by order, then name. + slices.SortStableFunc(all, func(a, b owned) int { + ma, _ := splitID(a.id) + mb, _ := splitID(b.id) + if c := cmp.Compare(ma, mb); c != 0 { + return c + } + if c := cmp.Compare(a.order(), b.order()); c != 0 { + return c + } + _, na := splitID(a.id) + _, nb := splitID(b.id) + return cmp.Compare(na, nb) + }) + for _, it := range all { + module, _ := splitID(it.id) + value := strings.TrimSuffix(it.text, "\n") + if why := literalProblem(value); why != "" { + return "", fmt.Errorf("%s's item %s is %q on this machine, which %s: a value is a literal both readers "+ + "take alike", module, it.id, value, why) + } + switch it.attributes["kind"] { + case "variable": + n := it.attributes["name"] + if n == "" { + return "", fmt.Errorf("%s's item %s is a variable and has no name", module, it.id) + } + if first, taken := setter[n]; taken { + return "", fmt.Errorf("%s and %s both set %s on this machine; the account has one environment, so "+ + "one of them must stop setting it (novox/hq ADR 0203)", first, module, n) + } + setter[n] = module + variables = append(variables, envVariable{module, n, value}) + case "path": + if strings.Contains(value, ":") { + return "", fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator", + module, value) + } + if value == "" { + return "", fmt.Errorf("%s's item %s is a PATH entry and says none", module, it.id) + } + if placed[value] { + continue + } + placed[value] = true + e := envEntry{module: module, entry: value, order: it.order(), item: it.id} + switch it.attributes["at"] { + case "start": + start = append(start, e) + case "end": + end = append(end, e) + default: + return "", fmt.Errorf("%s's item %s is a PATH entry and is not at start or end", module, it.id) + } + default: + return "", fmt.Errorf("%s's item %s is of the environment and is not kind=variable or kind=path", module, + it.id) + } + } + // Variables per module, sorted by name within it. + slices.SortStableFunc(variables, func(a, b envVariable) int { + return cmp.Or(cmp.Compare(a.module, b.module), cmp.Compare(a.name, b.name)) + }) + if renderer == EnvironmentSystemd { + return systemdText(variables, start, end), nil + } + return posixText(variables, start, end), nil +} + +func posixText(variables []envVariable, start, end []envEntry) string { + var b strings.Builder + named := "" + for _, v := range variables { + if v.module != named { + fmt.Fprintf(&b, "# %s\n", v.module) + named = v.module + } + fmt.Fprintf(&b, "export %s='%s'\n", v.name, v.value) + } + named = "" + for i := len(start) - 1; i >= 0; i-- { + p := start[i] + if p.module != named { + fmt.Fprintf(&b, "# %s\n", p.module) + named = p.module + } + fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n", + p.entry, p.entry) + } + named = "" + for _, p := range end { + if p.module != named { + fmt.Fprintf(&b, "# %s\n", p.module) + named = p.module + } + fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n", + p.entry, p.entry) + } + if len(start)+len(end) > 0 { + b.WriteString("export PATH\n") + } + return b.String() +} + +func systemdText(variables []envVariable, start, end []envEntry) string { + var b strings.Builder + named := "" + for _, v := range variables { + if v.module != named { + fmt.Fprintf(&b, "# %s\n", v.module) + named = v.module + } + fmt.Fprintf(&b, "%s=%s\n", v.name, v.value) + } + line := func(entries []envEntry, format string) { + if len(entries) == 0 { + return + } + var modules, paths []string + seen := map[string]bool{} + for _, p := range entries { + if !seen[p.module] { + seen[p.module] = true + modules = append(modules, p.module) + } + paths = append(paths, p.entry) + } + fmt.Fprintf(&b, "# %s\n"+format+"\n", strings.Join(modules, ", "), strings.Join(paths, ":")) + } + line(start, "PATH=%s${PATH:+:$PATH}") + line(end, "PATH=${PATH:+$PATH:}%s") + return b.String() +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment_test.go new file mode 100644 index 00000000..46a7922a --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/environment_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The session environment's collection (design 57 L3, issue 795), filled by this client from typed items, is what the +// controller composed from the old forms before the move. testdata/environment holds that composition for a machine +// with the five contributors and for one with zsh alone; mesh-controller's internal/catalogue +// TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot holds the not handed half against the same files. + +type envItem struct{ module, name, kind, attr, value string } + +// theCataloguesEnvironment is the five contributors' environment as the catalogue declared it before the move. +var theCataloguesEnvironment = []envItem{ + {"adwaita", "gtk-theme", "variable", "GTK_THEME", "Adwaita:dark"}, + {"adwaita", "gtk2-rc-files", "variable", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"}, + {"adwaita", "qt-qpa-platformtheme", "variable", "QT_QPA_PLATFORMTHEME", "qt6ct"}, + {"adwaita", "qt-style-override", "variable", "QT_STYLE_OVERRIDE", "Fusion"}, + {"adwaita", "qt-select", "variable", "QT_SELECT", "6"}, + {"adwaita", "xcursor-theme", "variable", "XCURSOR_THEME", "Adwaita"}, + {"adwaita", "xcursor-size", "variable", "XCURSOR_SIZE", "24"}, + {"clipmenu", "cm-selections", "variable", "CM_SELECTIONS", "clipboard"}, + {"clipmenu", "cm-max-clips", "variable", "CM_MAX_CLIPS", "500"}, + {"clipmenu", "cm-histlength", "variable", "CM_HISTLENGTH", "15"}, + {"i3", "xdg-current-desktop", "variable", "XDG_CURRENT_DESKTOP", "i3"}, + {"i3", "xdg-session-desktop", "variable", "XDG_SESSION_DESKTOP", "i3"}, + {"xterm", "terminal", "variable", "TERMINAL", "xterm"}, + {"zsh", "editor", "variable", "EDITOR", "vim"}, + {"zsh", "visual", "variable", "VISUAL", "vim"}, + {"zsh", "xdg-config-home", "variable", "XDG_CONFIG_HOME", "${machine:account-home}/.config"}, + {"zsh", "path-1", "path:10", "", "${machine:account-home}/.local/bin"}, + {"zsh", "path-2", "path:20", "", "${machine:account-home}/scripts"}, + {"zsh", "path-3", "path:30", "", "${machine:account-home}/scripts/bin"}, +} + +func envPlans(t *testing.T, items []envItem) (collector Plan, contributors map[string]Plan) { + t.Helper() + posix := "# header\n${collected:environment:posix}" + systemd := "${environment:systemd}" + tpl, _ := templates(t, map[string]string{"posix": posix, "systemd": systemd}) + facts := map[string]string{"machine:account-home": "/home/op"} + collector = Plan{Templates: tpl, Placement: facts, + Files: []PlannedFile{{ID: "posix", Path: "/env/environment.sh", Template: "posix", OwnerAtRuntime: "owned"}, + {ID: "systemd", Path: "/env/50-mesh.conf", Template: "systemd", OwnerAtRuntime: "owned"}}, + Collections: []PlannedCollection{{Name: "environment", Comment: "#"}}} + texts := map[string]map[string]string{} + planned := map[string][]PlannedItem{} + for _, it := range items { + if texts[it.module] == nil { + texts[it.module] = map[string]string{} + } + texts[it.module]["env-"+it.name] = it.value + "\n" + attrs := map[string]string{"kind": "variable", "name": it.attr} + if strings.HasPrefix(it.kind, "path") { + attrs = map[string]string{"kind": "path", "at": "start", "order": strings.TrimPrefix(it.kind, "path:")} + } + planned[it.module] = append(planned[it.module], PlannedItem{Collection: "environment", Name: it.name, + Attributes: attrs, Template: "env-" + it.name}) + } + contributors = map[string]Plan{} + for m := range texts { + tp, _ := templates(t, texts[m]) + contributors[m] = Plan{Templates: tp, Items: planned[m], Placement: facts} + } + return collector, contributors +} + +func TestNatsTheEnvironmentFilledFromTypedItemsIsWhatTheOldContributionMade(t *testing.T) { + for name, only := range map[string]string{"all": "", "zsh": "zsh"} { + t.Run(name, func(t *testing.T) { + var items []envItem + for _, it := range theCataloguesEnvironment { + if only == "" || it.module == only { + items = append(items, it) + } + } + collector, contributors := envPlans(t, items) + b := startBus(t) + e := startEngine(t, map[string]string{"/env/environment.sh": "owned", "/env/50-mesh.conf": "owned"}) + b.assign(t, "node-env", collector) + for m, p := range contributors { + b.assign(t, m, p) + } + aClient(t, b, e, t.TempDir(), time.Hour) + for file, golden := range map[string]string{"/env/environment.sh": "posix", "/env/50-mesh.conf": "systemd"} { + want, err := os.ReadFile(filepath.Join("testdata", "environment", name+"."+golden)) + if err != nil { + t.Fatal(err) + } + prefix := "" + if golden == "posix" { + prefix = "# header\n" + } + eventually(t, file+" is what it was", func() bool { return e.read(t, file) == prefix+string(want) }) + } + }) + } +} + +func TestTheEnvironmentRefusesAVariableTwoModulesSetAndAColonOnPATH(t *testing.T) { + items := map[string]itemValue{ + "a": {text: "1\n", attributes: map[string]string{"kind": "variable", "name": "X"}}, + "b": {text: "2\n", attributes: map[string]string{"kind": "variable", "name": "X"}}, + } + if _, err := environmentText(items, EnvironmentPOSIX); err == nil || !strings.Contains(err.Error(), "both set X") { + t.Fatalf("two modules setting X: %v", err) + } + items = map[string]itemValue{"a": {text: "/x:/y\n", attributes: map[string]string{"kind": "path", "at": "end"}}} + if _, err := environmentText(items, EnvironmentSystemd); err == nil || !strings.Contains(err.Error(), "colon") { + t.Fatalf("a colon on PATH: %v", err) + } + if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") { + t.Fatal("an upper-case name is the environment's alone") + } +} + +// The catalogue's own modules, as they are declared now: node-env's two files and the five contributors' items, read +// from their module.json and templates, fill to the files the old form composed (issue 795). +func TestNatsTheEnvironmentOfTheCataloguesModulesIsWhatTheOldFormComposed(t *testing.T) { + modules := filepath.Join("..", "..", "..") + type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } + read := func(path string) []byte { + b, err := os.ReadFile(filepath.Join(modules, path)) + if err != nil { + t.Fatal(err) + } + return b + } + for name, only := range map[string][]string{"all": {"adwaita", "clipmenu", "i3", "xterm", "zsh"}, "zsh": {"zsh"}} { + t.Run(name, func(t *testing.T) { + facts := map[string]string{"machine:account-home": "/home/op"} + tpl, _ := templates(t, map[string]string{"posix": string(read("node-env/files/environment.sh")), + "systemd": string(read("node-env/files/50-mesh.conf"))}) + b := startBus(t) + e := startEngine(t, map[string]string{"/env/environment.sh": "owned", "/env/50-mesh.conf": "owned"}) + b.assign(t, "node-env", Plan{Templates: tpl, Placement: facts, + Files: []PlannedFile{{ID: "posix", Path: "/env/environment.sh", Template: "posix", OwnerAtRuntime: "owned"}, + {ID: "systemd", Path: "/env/50-mesh.conf", Template: "systemd", OwnerAtRuntime: "owned"}}, + Collections: []PlannedCollection{{Name: "environment", Comment: "#"}}}) + for _, m := range only { + var manifest struct { + Moved bool `json:"moved"` + Items []item `json:"items"` + } + if err := json.Unmarshal(read(m+"/module.json"), &manifest); err != nil { + t.Fatal(err) + } + if !manifest.Moved || len(manifest.Items) == 0 { + t.Fatalf("%s is not a moved contributor of items", m) + } + texts := map[string]string{} + var planned []PlannedItem + for _, it := range manifest.Items { + id := filepath.Base(it.Template) + texts[id] = string(read(m + "/" + it.Template)) + planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name, + Attributes: it.Attributes, Template: id}) + } + tp, _ := templates(t, texts) + b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts}) + } + aClient(t, b, e, t.TempDir(), time.Hour) + for file, golden := range map[string]string{"/env/environment.sh": "posix", "/env/50-mesh.conf": "systemd"} { + want, err := os.ReadFile(filepath.Join("testdata", "environment", name+"."+golden)) + if err != nil { + t.Fatal(err) + } + // node-env's own files hold their header, which is the module's, then the collected text. + header := strings.SplitAfter(string(read("node-env/files/"+map[string]string{"posix": "environment.sh", + "systemd": "50-mesh.conf"}[golden])), "\n") + head := "" + for _, l := range header { + if strings.HasPrefix(l, "#") { + head += l + } + } + eventually(t, file+" is what it was", func() bool { return e.read(t, file) == head+string(want) }) + } + }) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/fill.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/fill.go new file mode 100644 index 00000000..06eaf17d --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/fill.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "time" +) + +// The node-engine's narrow local write verb (novox/hq ADR 0349 clause 4, design 57 P2; mesh-host internal/filling): +// one unix socket, one JSON request a line — a filled file's path and its content — and one answer. **The client +// fills; the node-engine writes**, as the declared file, with its owner, mode and who owns it at runtime: an owned +// file whole and written back whenever it differs, a seed only when absent, a managed file's own keys or block +// only. And it makes what reads the file take the change (restart-on, reload-on, a container that reads it). So +// the client never writes a machine's file itself and needs no right to. +// +// The request and the answer are copied here, field for field, from mesh-host internal/filling: the client is +// the one other reader of that format, and the node-engine's decoder refuses a field it does not know. + +// DefaultSocket is where the node-engine serves the write. +const DefaultSocket = "/run/nox-mesh-host/fill.sock" + +// FillRequest is one write. +type FillRequest struct { + Path string `json:"path"` + Content []byte `json:"content"` +} + +// FillAnswer is what came of it: Refused is a write not attempted, Failed one attempted that did not complete, +// each with why; otherwise Action is what the write did (created, updated, corrected, unchanged, kept) and Digest +// what the file holds now. +type FillAnswer struct { + Path string `json:"path,omitempty"` + Action string `json:"action,omitempty"` + Digest string `json:"digest,omitempty"` + Refused string `json:"refused,omitempty"` + Failed string `json:"failed,omitempty"` +} + +// Filler has the node-engine write a filled file. +type Filler interface { + Fill(ctx context.Context, path string, content []byte) (FillAnswer, error) +} + +// SocketFiller is the node-engine's socket. +type SocketFiller struct { + Socket string + // Busy is how long a write the node-engine says it is busy for waits before it is asked again, and Tries how + // many times it is asked. + Busy time.Duration + Tries int +} + +// Fill asks the node-engine to write one file, again while it says it is busy. +func (s SocketFiller) Fill(ctx context.Context, path string, content []byte) (FillAnswer, error) { + tries := max(s.Tries, 1) + var a FillAnswer + var err error + for i := 0; i < tries; i++ { + if a, err = s.once(ctx, path, content); err != nil || !strings.HasPrefix(a.Refused, "busy") { + return a, err + } + select { + case <-ctx.Done(): + return a, ctx.Err() + case <-time.After(s.Busy): + } + } + return a, err +} + +func (s SocketFiller) once(ctx context.Context, path string, content []byte) (FillAnswer, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", s.Socket) + if err != nil { + return FillAnswer{}, fmt.Errorf("the node-engine's write socket %s: %w", s.Socket, err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if err := json.NewEncoder(conn).Encode(FillRequest{Path: path, Content: content}); err != nil { + return FillAnswer{}, fmt.Errorf("asking the node-engine to write %s: %w", path, err) + } + var a FillAnswer + if err := json.NewDecoder(conn).Decode(&a); err != nil { + return FillAnswer{}, fmt.Errorf("the node-engine's answer for %s: %w", path, err) + } + return a, nil +} + +// errFill is a write the node-engine refused or failed, with its word. +func errFill(a FillAnswer) error { + switch { + case a.Refused != "": + return errors.New("the node-engine refused it: " + a.Refused) + case a.Failed != "": + return errors.New("the node-engine's write failed: " + a.Failed) + } + return nil +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/i3_golden_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/i3_golden_test.go new file mode 100644 index 00000000..0097891f --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/i3_golden_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// i3's window-manager configuration, filled by this client from the items of the collection +// contribution:node-display-session:config that its contributors declare, is byte for byte what the controller +// composed from their old contributions before the move (design 57 L3, issue 848). The module files are the +// catalogue's own; testdata/i3 holds the configuration as the controller composed it, for a machine with all nine +// contributors (dunst, rofi, i3status-rust, asus-zephyrus-g14, clipmenu, feh, slack, polychromatic and i3 itself) and for +// one with only i3. The golden files were composed once, by a one-off run of mesh-controller's catalogue at the +// feature branch's head over the catalogue as it was before the move; the controller's composition for a collector +// not handed, from the same items, was compared with them in the same way and is byte for byte the same. + +func TestNatsI3sConfigFilledFromItsContributorsItemsIsWhatTheOldContributionsMade(t *testing.T) { + modules := filepath.Join("..", "..", "..") + read := func(path string) string { + b, err := os.ReadFile(filepath.Join(modules, path)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + const collection = "contribution:node-display-session:config" + all := []string{"asus-zephyrus-g14", "clipmenu", "dunst", "feh", "i3status-rust", "polychromatic", "rofi", "slack"} + for name, contributors := range map[string][]string{"all": all, "i3-only": nil} { + t.Run(name, func(t *testing.T) { + facts := map[string]string{"machine:account-home": "/home/op"} + tpl, _ := templates(t, map[string]string{"config": read("i3/files/config")}) + collector := Plan{Templates: tpl, Placement: facts, + Files: []PlannedFile{{ID: "config", Path: "/i3/config", Template: "config", OwnerAtRuntime: "owned"}}, + Collections: []PlannedCollection{{Name: collection, Comment: "#"}}} + b := startBus(t) + e := startEngine(t, map[string]string{"/i3/config": "owned"}) + b.assign(t, "i3", collector) + for _, m := range contributors { + var c struct { + Moved bool `json:"moved"` + Contributions []any `json:"contributions"` + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(read(m+"/module.json")), &c); err != nil { + t.Fatal(err) + } + if !c.Moved || len(c.Contributions) != 0 { + t.Fatalf("%s is not moved or still holds contributions", m) + } + texts := map[string]string{} + var planned []PlannedItem + for _, it := range c.Items { + if it.Collection != collection { + continue + } + id := filepath.Base(it.Template) + texts[id] = read(m + "/" + it.Template) + planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id, + Attributes: it.Attributes}) + } + if len(planned) != 1 { + t.Fatalf("%s declares %d items of the window-manager configuration, want 1", m, len(planned)) + } + tp, _ := templates(t, texts) + b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts}) + } + aClient(t, b, e, t.TempDir(), time.Hour) + want, err := os.ReadFile(filepath.Join("testdata", "i3", name+".config")) + if err != nil { + t.Fatal(err) + } + // The golden holds asus-zephyrus-g14's directory as @ASUS_LIB@: the catalogue's path check keeps one module's + // path out of another module's files, and the golden is the one file that holds every contributor's lines. + golden := strings.ReplaceAll(string(want), "@ASUS_LIB@", "/usr/local/lib/asus-zephyrus-g14") + eventually(t, "i3's configuration is what it was", func() bool { return e.read(t, "/i3/config") == golden }) + if t.Failed() { + t.Logf("got:\n%s", e.read(t, "/i3/config")) + } + }) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/main.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/main.go new file mode 100644 index 00000000..3a127e23 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/main.go @@ -0,0 +1,170 @@ +// mesh-provisioner-client: the provisioner's client, holder of the node-provisioner seat on every node (novox/hq +// ADR 0349 clauses 3 to 5, design 57 P5, issue 671). A service of its own, outside the tool runner, on a bus account +// of its own (`provisioner.`, mesh-controller internal/broker, design 57 P3): the vault hands a secret only to +// an account of its own, and the bus grants this one its machine's keys and nothing else. It fills the templates of +// each module handed to the provisioner on its node and has the node-engine write them (client.go). +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// Credential is the client's account as the controller delivers it, its own secret `provisioner-broker` +// (mesh-controller cmd/mesh-controller issueProvisionerClient): the bus, its certificate by fingerprint, the +// machine, the user and its password. +type Credential struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + User string `json:"user"` + Password string `json:"password"` +} + +func readCredential(path string) (Credential, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Credential{}, fmt.Errorf("the client's account cannot be read: %w", err) + } + var c Credential + if err := json.Unmarshal(raw, &c); err != nil { + return Credential{}, errors.New("the client's account is not the JSON the mesh delivers (url, fingerprint, " + + "node, user, password)") + } + if c.URL == "" || c.Node == "" || c.User == "" || c.Password == "" { + return Credential{}, errors.New("the client's account names no bus, machine, user or password") + } + if c.User != "provisioner."+c.Node { + return Credential{}, fmt.Errorf("the account %s is not machine %s's provisioner client's", c.User, c.Node) + } + return c, nil +} + +// pinnedTo accepts exactly the certificate with this SHA-256: the mesh's bus presents one of its own, in no trust +// store (mesh-controller internal/broker PinnedToFingerprint). +func pinnedTo(want string) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter + MinVersion: tls.VersionTLS12, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return errors.New("the bus presented no certificate") + } + sum := sha256.Sum256(rawCerts[0]) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("the bus presented a certificate this mesh does not know (%s)", got) + } + return nil + }, + } +} + +func main() { + credential := flag.String("credential", os.Getenv("MESH_PROVISIONER_CREDENTIAL"), + "the client's account, its own secret provisioner-broker (MESH_PROVISIONER_CREDENTIAL)") + socket := flag.String("socket", DefaultSocket, "the node-engine's socket for writing a filled file") + stateDir := flag.String("state", os.Getenv("MESH_PROVISIONER_STATE"), + "where the client keeps the templates it fetched (MESH_PROVISIONER_STATE)") + drift := flag.Duration("drift", time.Minute, "how often every filled file is written again") + flag.Parse() + if *credential == "" || *stateDir == "" { + logf("usage: mesh-provisioner-client -credential -state ") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := serve(ctx, *credential, *socket, *stateDir, *drift); err != nil && ctx.Err() == nil { + logf("[provisioner-client] %v", err) + os.Exit(1) + } +} + +// serve connects and runs the client until ctx ends. A run that stops — a watch ended, the bus came back — is run +// again from the current values, after a wait that grows to a minute. +func serve(ctx context.Context, credential, socket, stateDir string, drift time.Duration) error { + c, err := readCredential(credential) + if err != nil { + return err + } + back := make(chan struct{}, 1) + opts := []nats.Option{ + nats.UserInfo(c.User, c.Password), + nats.Name("mesh-provisioner-client " + c.Node), + // Answers come to the account's own inbox and no other (novox/hq design 25 §4). + nats.CustomInboxPrefix("_INBOX." + c.User), + nats.MaxReconnects(-1), + nats.ReconnectHandler(func(*nats.Conn) { + select { + case back <- struct{}{}: + default: + } + }), + } + if c.Fingerprint != "" { + opts = append(opts, nats.Secure(pinnedTo(c.Fingerprint))) + } + nc, err := nats.Connect(c.URL, opts...) + if err != nil { + return fmt.Errorf("cannot reach the bus at %s as %s: %w", c.URL, c.User, err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + return err + } + // Its own key, for the vault to seal each secret to: made now, kept in memory alone. + secrets, err := NewVaultSecrets(nc, c.User) + if err != nil { + return err + } + client := &Client{Node: c.Node, JS: js, Conn: nc, Drift: drift, Secrets: secrets, + Fill: SocketFiller{Socket: socket, Busy: time.Second, Tries: 10}, + Templates: &templateStore{dir: filepath.Join(stateDir, "templates"), fetch: HTTPFetch}} + wait := time.Second + for { + run, cancel := context.WithCancel(ctx) + go func() { + select { + case <-back: + logf("[provisioner-client] the bus is back; filling from the current values") + cancel() + case <-run.Done(): + } + }() + started := time.Now() + err := client.Run(run) + cancel() + if ctx.Err() != nil { + return nil + } + if time.Since(started) > time.Minute { + wait = time.Second + } + if err != nil && !errors.Is(err, context.Canceled) { + logf("[provisioner-client] stopped (%v); running again in %s", err, wait) + } + select { + case <-ctx.Done(): + return nil + case <-time.After(wait): + } + wait = min(2*wait, time.Minute) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_test.go new file mode 100644 index 00000000..91b55c6f --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + "time" +) + +// The collectors design 57 L2 moves (issue 681): zsh, power and triggerhappy, each with every contributor the +// catalogue gives it, filled by this client from the modules' real templates, write the file the controller composed +// from the old forms before the move, byte for byte (composedBeforeTheMove). + +// catalogueModules is where the catalogue's modules are, from this package. +const catalogueModules = "../../.." + +// repoManifest is the part of a module's manifest in the catalogue this test reads. +type repoManifest struct { + Module string `json:"module"` + Templates string `json:"templates"` + Moved bool `json:"moved"` + Items []struct { + Collection string `json:"collection"` + Template string `json:"template"` + } `json:"items"` +} + +var oldNamespacePlaceholder = regexp.MustCompile(`\$\{((?:shell|contribution):[^}]*)\}`) + +func TestNatsTheCollectorsL2MovedFillFromTheirContributorsItemsAsTheyWereComposed(t *testing.T) { + collectors := []struct{ module, template, want string }{ + {"zsh", "files/zshrc", "zsh.zshrc"}, + {"triggerhappy", "files/triggers.conf", "triggerhappy.triggers.conf"}, + {"power", "templates/mesh-power/moments/after-wake", "power.after-wake"}, + } + paths, err := filepath.Glob(filepath.Join(catalogueModules, "*", "module.json")) + if err != nil || len(paths) == 0 { + t.Fatalf("no catalogue at %s: %v", catalogueModules, err) + } + var modules []repoManifest + for _, p := range paths { + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + var m repoManifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", p, err) + } + modules = append(modules, m) + } + for _, c := range collectors { + t.Run(c.module, func(t *testing.T) { + text, err := os.ReadFile(filepath.Join(catalogueModules, c.module, c.template)) + if err != nil { + t.Fatal(err) + } + want, ok := composedBeforeTheMove[c.want] + if !ok { + t.Fatalf("no file composed before the move for %s", c.want) + } + path := "/etc/" + c.module + "/collected" + b := startBus(t) + e := startEngine(t, map[string]string{path: "owned"}) + tpl, _ := templates(t, map[string]string{"file": string(text)}) + collector := Plan{Templates: tpl, Files: []PlannedFile{{ID: "file", Path: path, Template: "file", + OwnerAtRuntime: "owned"}}} + names := map[string]bool{} + for _, f := range oldNamespacePlaceholder.FindAllStringSubmatch(string(text), -1) { + if !names[f[1]] { + names[f[1]] = true + // The controller plans an old namespace's collection with the comment it wrote: "#" for these. + collector.Collections = append(collector.Collections, PlannedCollection{Name: f[1], Comment: "#"}) + } + } + b.assign(t, c.module, collector) + contributors := 0 + for _, m := range modules { + for _, it := range m.Items { + if !names[it.Collection] { + continue + } + if !m.Moved { + t.Errorf("%s contributes an item and is not moved", m.Module) + } + item, err := os.ReadFile(filepath.Join(catalogueModules, m.Module, it.Template)) + if err != nil { + t.Fatal(err) + } + b.assign(t, m.Module, contributor(t, it.Collection, string(item))) + contributors++ + } + } + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, c.module+"'s file as the controller composed it", func() bool { + return e.read(t, path) == want + }) + if t.Failed() { + t.Logf("%d contributors; got:\n%s", contributors, e.read(t, path)) + } + }) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_want_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_want_test.go new file mode 100644 index 00000000..f9a1b867 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_collectors_want_test.go @@ -0,0 +1,10 @@ +package main + +// The files the controller composed from the old forms at the feature branch before design 57 L2 moved their +// collectors (issue 681): zsh's rc, triggerhappy's triggers and power's after-wake moment, every contributor placed. +// Kept in a test file, where the paths check (novox/hq ADR 0260) reads no other module's path. +var composedBeforeTheMove = map[string]string{ + "zsh.zshrc": "# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code\n# other modules contribute in three slots. It is replaced at every push. Everything below it is\n# yours, kept as you wrote it, and runs after it, so your lines win.\n\n# History, kept across sessions and shared between open shells. Without these zsh saves none:\n# SAVEHIST defaults to 0 (novox/hq to-be 41, found on review 2026-10-04).\nHISTFILE=\"$HOME/.zsh_history\"\nHISTSIZE=100000\nSAVEHIST=100000\nsetopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS\n\n# The completion system. Nothing initialised it, so only zsh's basic completion ran. Its cache\n# goes under ~/.cache rather than beside ~/.zshrc.\n[[ -d \"$HOME/.cache/zsh\" ]] || mkdir -p \"$HOME/.cache/zsh\"\nautoload -Uz compinit && compinit -d \"$HOME/.cache/zsh/zcompdump\"\nzstyle ':completion:*' menu select\n\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# Keybindings\nbindkey \"^[[H\" beginning-of-line # Home\nbindkey \"^[OH\" beginning-of-line # Home\nbindkey \"^A\" beginning-of-line # Ctrl + A\nbindkey \"^[[F\" end-of-line # End\nbindkey \"^[OF\" end-of-line # End\nbindkey \"^E\" end-of-line # Ctrl + E\nbindkey \"^[[3~\" delete-char # Del\n\n# Colour support of ls and friends\nif [ -x /usr/bin/dircolors ]; then\n test -r $HOME/.dircolors && eval \"$(dircolors -b $HOME/.dircolors)\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias dir='dir --color=auto'\n alias vdir='vdir --color=auto'\n alias grep='grep --color=auto'\n alias fgrep='fgrep --color=auto'\n alias egrep='egrep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\n\n# A throwaway container, and what takes the space under a directory\nalias drun='docker run -it --rm'\ndisksize() {\n du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\nsudo-disksize() {\n sudo du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\n\n# claude-code\n# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(.` of mesh-controller_assignments +// (mesh-controller internal/inventory Provisioning), which this client reads for its own machine alone. An +// assignment with no plan is a module the controller has not handed to the provisioner: its files are the +// node-engine's, from the declaration, and this client leaves them alone (ADR 0349 clause 17). A module only pulled +// in by another on this node, and handed, has a value of its own there, which names what requires it and no time it +// was assigned (issue 765): read and filled the same way. + +// Assignment is one value of the assignments mirror. **Lenient at the top**, so a field the controller adds to an +// assignment (design 54 §1) does not stop the client; **strict in the plan**, which is the client's to act on. +type Assignment struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning json.RawMessage `json:"provisioning,omitempty"` +} + +// Plan is one module's plan on this node. +type Plan struct { + Templates Templates `json:"templates"` + Files []PlannedFile `json:"files"` + Placement map[string]string `json:"placement,omitempty"` + // Secrets are the module's own secrets its declaration on this node names (design 57 V4): each one its + // templates name as `${secret:}`, and each it keeps in a file of its own at Path. The vault hands this + // client a secret only when the plan names it (mesh-vault askers.go). + Secrets []PlannedSecret `json:"secrets,omitempty"` + // Items are the module's items of collections (ADR 0349 clause 13, design 57 L1): each filled from its template + // like a file, and recorded in mesh-provisioner_collect for this node instead of written to a file. The collector + // of each, on this node, is filled from every contributor's item there. + Items []PlannedItem `json:"items,omitempty"` + // Collections are the collections the module's templates name, as their collector: `${collected:}`, or + // one of the three old namespaces read as a collection of the same name (`${shell:zsh:normal}` is the collection + // `shell:zsh:normal`). Each with the comment its tool writes a line in, which introduces each contributor's + // item with the contributor's name; none for a tool without one. + Collections []PlannedCollection `json:"collections,omitempty"` + // Provisions are what the module requires on this node, each with the provider serving it (issue 760), which + // the vault acts on and this client only reads. An entry with a name is a credential this client fills from the + // vault (issue 757): the value a provider's software issued for this module on this machine. Its templates name + // one as `${secret:}`, and Path, when set, is a file holding it alone. The vault hands one only when the + // plan names it (mesh-vault askers.go). + Provisions []PlannedProvision `json:"provisions,omitempty"` +} + +// PlannedItem is one item the module contributes: to which collection, and the template it is filled from. +// +// A module may give several items to one collection, each with a Name of its own, and any with Attributes a +// placeholder selecting by them is matched against (design 57 L3, issue 777). An item whose capability the machine +// lacks is not in the plan: the controller judged that. +type PlannedItem struct { + Collection string `json:"collection"` + Name string `json:"name,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Template string `json:"template"` +} + +// key is the item among its module's: the collection, and `/` for a named item. +func (it PlannedItem) key() string { + if it.Name == "" { + return it.Collection + } + return it.Collection + "/" + it.Name +} + +// PlannedCollection is one collection the module collects. +type PlannedCollection struct { + Name string `json:"name"` + Comment string `json:"comment,omitempty"` +} + +// PlannedProvision is one provision the module requires, under its local name (empty for the ordinary one), and +// its provider: the machine and the module serving it. With a name, a credential of it this client fills: Name as +// the module's files name it (`${secret:}`), and the file holding it alone, when there is one. +type PlannedProvision struct { + Name string `json:"name,omitempty"` + Provision string `json:"provision"` + Local string `json:"local,omitempty"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module,omitempty"` + // CredentialForm is the form the provider's offer declares for its credential (issue 768), which the vault acts + // on and this client only reads: one of credentialForms, or none from a controller before it. + CredentialForm string `json:"credential-form,omitempty"` + Path string `json:"path,omitempty"` + // Contributes is what this module tells the provider about itself for this provision (a database's name, its + // extensions): the consumer manifest's `contributes`, which the provider's helper reads and the client keeps. + Contributes map[string]any `json:"contributes,omitempty"` +} + +// issuedForHolder is whether the entry names a value its provider's software issued for this holder alone (issue +// 757). An entry of the login-per-consumer form that names a file (issue 779) is still the bind it was: its provider +// lists this consumer and its bound values are read from it. +func (p PlannedProvision) issuedForHolder() bool { return p.Name != "" && p.CredentialForm != "pair" } + +// credentialForms are the forms a provider's offer may declare for its credential (mesh-vault pair.go): one value +// every holder shares, a login per consumer, a value its software issues, or one a person gives. +var credentialForms = []string{"shared", "pair", "issued", "given"} + +// PlannedSecret is one secret of the module's own, by its name in the module's manifest; Path, when set, is the +// file holding it alone (the manifest's `own-secrets`), which the client fills with its value and nothing else. +type PlannedSecret struct { + Name string `json:"name"` + Path string `json:"path,omitempty"` +} + +// Templates is the archive the module's build packed its templates directory into: fetched from Source, and used only when it +// hashes to Digest. +type Templates struct { + Source string `json:"source"` + Digest string `json:"digest"` +} + +// PlannedFile is one file the provisioner fills. +type PlannedFile struct { + ID string `json:"id"` + Path string `json:"path"` + Template string `json:"template"` + OwnerAtRuntime string `json:"owner-at-runtime"` + Takes string `json:"takes,omitempty"` +} + +// The placeholder kinds placement answers, as the plan's Placement keys begin (ADR 0349 clause 2). +var placementKinds = []string{"dir", "access", "port", "seat", "machine", "backup"} + +var ( + digestShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + // secretShape is a secret's name as a manifest writes it and `${secret:…}` names it (mesh-controller + // internal/catalogue secrets_into_files.go). + secretShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + // collectionShape is a collection's name (mesh-controller internal/catalogue collections.go): words joined by + // colons, so an old namespace's placeholder names one as it reads, `shell:zsh:normal`. + collectionShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`) + // itemNameShape, attributeKeyShape and attributeValueShape are an item's name and attributes (mesh-controller + // internal/catalogue collections.go); none holds a colon, a comma or an equals sign. + itemNameShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + attributeKeyShape = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + attributeValueShape = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + // nodeShape is a machine's name as the vault writes it in a secret's name (mesh-vault intake.go namePart). + nodeShape = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + ownerWords = []string{"owned", "seed", "managed"} + takeWords = []string{"restart-on", "reload-on", "read-on-use"} +) + +// errNoPlan is an assignment the controller has not handed to the provisioner. +var errNoPlan = errors.New("no plan") + +// planOf reads an assignment's value: its module and its plan, errNoPlan when it has none, or why the plan cannot +// be acted on. +func planOf(node, key string, value []byte) (string, Plan, error) { + var a Assignment + if err := json.Unmarshal(value, &a); err != nil { + return "", Plan{}, fmt.Errorf("the assignment %s is not the controller's JSON: %w", key, err) + } + if a.Node != node || a.Node+"."+a.Module != key { + return "", Plan{}, fmt.Errorf("the assignment %s names node %q and module %q, not its key's", key, a.Node, + a.Module) + } + if len(a.Provisioning) == 0 || string(a.Provisioning) == "null" { + return a.Module, Plan{}, errNoPlan + } + var p Plan + dec := json.NewDecoder(bytes.NewReader(a.Provisioning)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be read: %w", a.Module, err) + } + if wrong := p.problems(); len(wrong) > 0 { + return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be acted on: %s", a.Module, + strings.Join(wrong, "; ")) + } + return a.Module, p, nil +} + +func (p Plan) problems() []string { + var out []string + if p.Templates.Source == "" { + out = append(out, "it names no source for its templates") + } + if !digestShape.MatchString(p.Templates.Digest) { + out = append(out, fmt.Sprintf("its templates' digest %q is not sha256:<64 hex>", p.Templates.Digest)) + } + if len(p.Files) == 0 && len(p.Items) == 0 { + out = append(out, "it fills no file and contributes no item") + } + seen := map[string]bool{} + for _, f := range p.Files { + switch { + case !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path: + out = append(out, fmt.Sprintf("%s: %q is not a clean absolute path", f.ID, f.Path)) + case seen[f.Path]: + out = append(out, fmt.Sprintf("%s: %s is named twice", f.ID, f.Path)) + } + seen[f.Path] = true + if !plainTemplate(f.Template) { + out = append(out, fmt.Sprintf("%s: template %q is not a plain path inside the templates archive", f.ID, + f.Template)) + } + if !slices.Contains(ownerWords, f.OwnerAtRuntime) { + out = append(out, fmt.Sprintf("%s: owner-at-runtime %q is not owned, seed or managed", f.ID, + f.OwnerAtRuntime)) + } + if f.Takes != "" && !slices.Contains(takeWords, f.Takes) { + out = append(out, fmt.Sprintf("%s: takes %q is not restart-on, reload-on or read-on-use", f.ID, f.Takes)) + } + } + named := map[string]bool{} + for _, sec := range p.Secrets { + if !secretShape.MatchString(sec.Name) { + out = append(out, fmt.Sprintf("secret %q is not a secret's name", sec.Name)) + } + if named[sec.Name] { + out = append(out, fmt.Sprintf("secret %s is named twice", sec.Name)) + } + named[sec.Name] = true + if sec.Path == "" { + continue + } + switch { + case !filepath.IsAbs(sec.Path) || filepath.Clean(sec.Path) != sec.Path: + out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", sec.Name, sec.Path)) + case seen[sec.Path]: + out = append(out, fmt.Sprintf("secret %s: %s is named twice", sec.Name, sec.Path)) + } + seen[sec.Path] = true + } + bound := map[[2]string]bool{} + for _, pr := range p.Provisions { + which := pr.Name + if which == "" { + which = pr.Provision + " " + pr.Local + } + switch { + case pr.Name == "": + case !secretShape.MatchString(pr.Name): + out = append(out, fmt.Sprintf("provision credential %q is not a secret's name", pr.Name)) + case named[pr.Name]: + out = append(out, fmt.Sprintf("secret %s is named twice", pr.Name)) + } + if pr.Name != "" { + named[pr.Name] = true + } + if !secretShape.MatchString(pr.Provision) { + out = append(out, fmt.Sprintf("%s: provision %q is not a provision's name", which, pr.Provision)) + } + if !nodeShape.MatchString(pr.Provider) { + out = append(out, fmt.Sprintf("%s: provider %q is not a machine's name", which, pr.Provider)) + } + if pr.CredentialForm != "" && !slices.Contains(credentialForms, pr.CredentialForm) { + out = append(out, fmt.Sprintf("%s: credential-form %q is not %s", which, pr.CredentialForm, + strings.Join(credentialForms, ", "))) + } + if key := [2]string{pr.Provision, pr.Local}; bound[key] { + out = append(out, fmt.Sprintf("%s: its provider is named twice", which)) + } else { + bound[key] = true + } + if pr.Path == "" { + continue + } + if pr.Name == "" { + out = append(out, fmt.Sprintf("%s: %s is a file with no credential to fill it with", which, pr.Path)) + continue + } + switch { + case !filepath.IsAbs(pr.Path) || filepath.Clean(pr.Path) != pr.Path: + out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", pr.Name, pr.Path)) + case seen[pr.Path]: + out = append(out, fmt.Sprintf("secret %s: %s is named twice", pr.Name, pr.Path)) + } + seen[pr.Path] = true + } + contributed := map[string]bool{} + for _, it := range p.Items { + if !collectionShape.MatchString(it.Collection) { + out = append(out, fmt.Sprintf("item %q is not a collection's name", it.Collection)) + } + if it.Name != "" && !itemNameShape.MatchString(it.Name) { + out = append(out, fmt.Sprintf("item %s: %q is not a name", it.Collection, it.Name)) + } + if contributed[it.key()] { + out = append(out, fmt.Sprintf("item %s is named twice: a module contributes one item to a collection, "+ + "or several each with a name of its own", it.key())) + } + contributed[it.key()] = true + for _, k := range sortedKeys(it.Attributes) { + if v := it.Attributes[k]; !attributeFits(it.Collection, k, v) { + out = append(out, fmt.Sprintf("item %s: attribute %s=%q is not a name and a value", it.key(), k, v)) + } else if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) { + out = append(out, fmt.Sprintf("item %s: order=%q is not a whole number from 0 to 99", it.key(), v)) + } + } + if !plainTemplate(it.Template) { + out = append(out, fmt.Sprintf("item %s: template %q is not a plain path inside the templates archive", + it.Collection, it.Template)) + } + } + collected := map[string]bool{} + for _, c := range p.Collections { + if !collectionShape.MatchString(c.Name) { + out = append(out, fmt.Sprintf("collection %q is not a collection's name", c.Name)) + } + if collected[c.Name] { + out = append(out, fmt.Sprintf("collection %s is named twice", c.Name)) + } + collected[c.Name] = true + if strings.ContainsAny(c.Comment, "\n\r") { + out = append(out, fmt.Sprintf("collection %s: its comment holds a line break", c.Name)) + } + } + for k := range p.Placement { + kind, name, ok := strings.Cut(k, ":") + if !ok || name == "" || !slices.Contains(placementKinds, kind) { + out = append(out, fmt.Sprintf("placement %q is not : of a placement kind", k)) + } + } + slices.Sort(out) + return out +} + +// plainTemplate is whether a template's path is a plain path inside the templates archive. +func plainTemplate(t string) bool { + return t != "" && !strings.HasPrefix(t, "/") && path.Clean(t) == t && t != ".." && !strings.HasPrefix(t, "../") +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/providers_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/providers_test.go new file mode 100644 index 00000000..4841a24c --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/providers_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// A plan names, for each provision its module requires, the provider serving it (design 57 V5a, issue 760): the +// client reads that strictly, before the controller writes it, and fills nothing from an entry with no credential; +// the vault acts on it. +func TestAPlanNamingItsProvidersIsRead(t *testing.T) { + value := `{"node":"g14","module":"app","provisioning":{` + + `"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` + + `"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` + + `{"provision":"s3-bucket","local":"hot","provider":"ace","provider-module":"minio"},` + + `{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` + + `"path":"/var/lib/app/forge.secret"}]}}` + _, p, err := planOf("g14", "g14.app", []byte(value)) + if err != nil { + t.Fatal(err) + } + want := []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}, + {Provision: "s3-bucket", Local: "hot", Provider: "ace", ProviderModule: "minio"}, + {Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge", + Path: "/var/lib/app/forge.secret"}} + if len(p.Provisions) != len(want) { + t.Fatalf("the plan's provisions are read as %+v, want %+v", p.Provisions, want) + } + for i := range want { + if !reflect.DeepEqual(p.Provisions[i], want[i]) { + t.Fatalf("the plan's provisions are read as %+v, want %+v", p.Provisions, want) + } + } + // Only the credential is a file to fill: an entry naming a provider alone fills nothing. + targets, why := fillTargets(p, map[string]string{"app.conf": "x"}) + if why != "" || len(targets) != 2 || targets[1].Path != "/var/lib/app/forge.secret" { + t.Fatalf("the files filled are %+v (%s)", targets, why) + } + for why, provisions := range map[string][]PlannedProvision{ + "a file and no credential's name": {{Provision: "p", Provider: "a", ProviderModule: "m", Path: "/var/lib/p"}}, + "one provision and local twice": {{Provision: "p", Local: "l", Provider: "a", ProviderModule: "m"}, + {Provision: "p", Local: "l", Provider: "b", ProviderModule: "m"}}, + "no provider": {{Provision: "p", ProviderModule: "m"}}, + } { + bad := p + bad.Provisions = provisions + if len(bad.problems()) == 0 { + t.Errorf("a plan with %s was taken", why) + } + } +} + +// Each entry names the credential form of its provider's offer (issue 768), which the vault acts on: the client +// reads it strictly, before the controller writes it, and refuses a form it does not know. +func TestAPlanNamingEachProvidersCredentialFormIsRead(t *testing.T) { + value := `{"node":"g14","module":"app","provisioning":{` + + `"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` + + `"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` + + `"credential-form":"pair"},` + + `{"provision":"sonarr-api","provider":"ace","provider-module":"sonarr","credential-form":"shared"},` + + `{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` + + `"credential-form":"issued","path":"/var/lib/app/forge.secret"},` + + `{"provision":"smtp-relay","provider":"ace","provider-module":"relay","credential-form":"given"}]}}` + _, p, err := planOf("g14", "g14.app", []byte(value)) + if err != nil { + t.Fatal(err) + } + var forms []string + for _, pr := range p.Provisions { + forms = append(forms, pr.CredentialForm) + } + if strings.Join(forms, ",") != "pair,shared,issued,given" { + t.Fatalf("the credential forms are read as %v", forms) + } + bad := strings.Replace(value, `"credential-form":"pair"`, `"credential-form":"one-for-all"`, 1) + if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil || !strings.Contains(err.Error(), "one-for-all") { + t.Fatalf("a credential form the client does not know was taken: %v", err) + } +} + +// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the client reads it +// strictly, keeps it on the entry, and still refuses any other field it does not know. +func TestAPlanEntryCarryingItsContributionIsRead(t *testing.T) { + value := `{"node":"g14","module":"app","provisioning":{` + + `"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` + + `"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` + + `"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}` + _, p, err := planOf("g14", "g14.app", []byte(value)) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}} + if len(p.Provisions) != 1 || !reflect.DeepEqual(p.Provisions[0].Contributes, want) { + t.Fatalf("the plan's provisions are read as %+v, want the contribution %v kept", p.Provisions, want) + } + bad := strings.Replace(value, `"contributes":`, `"contribution":`, 1) + if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil { + t.Error("a plan entry with a field the client does not know was taken") + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go new file mode 100644 index 00000000..34791bf9 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +// A module only pulled in by another on this node has a value of its own in the assignments mirror (mesh-controller +// internal/inventory AssignmentValues, issue 765): its plan, what requires it, and no time it was assigned. The +// client fills it exactly like an assigned module: its files, its secrets from the vault and its items. + +// pullIn writes a module's value as the controller writes one only pulled in, by requiredBy. +func (b *aBus) pullIn(t *testing.T, module, requiredBy string, plan Plan) { + t.Helper() + p, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"required-by":%q,`+ + `"provisioning":%s}`, node, module, requiredBy, p)) +} + +func TestAPulledInModulesValueIsReadForItsPlan(t *testing.T) { + plan := contributor(t, "shell:zsh:normal", "alias nox=mesh-cli") + p, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","required-by":"desk","provisioning":%s}`, p) + module, got, err := planOf("g14", "g14.mesh-cli", []byte(value)) + if err != nil || module != "mesh-cli" || len(got.Items) != 1 || got.Items[0].Collection != "shell:zsh:normal" { + t.Fatalf("a pulled-in module's value reads as %s %+v (%v)", module, got, err) + } + // Still strict in the plan. + spoilt := strings.Replace(value, `"items":`, `"surprise":1,"items":`, 1) + if _, _, err := planOf("g14", "g14.mesh-cli", []byte(spoilt)); err == nil { + t.Fatalf("a pulled-in module's plan with an unknown field is acted on: %s", spoilt) + } +} + +func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + vault := startVault(t, b) + const token, password = "tok-765-never-on-the-bus", "pw-765-never-on-the-bus" + vault.keep("own/g14/app/app-token", token, false) + vault.keep("own/g14/app/db-password", password, false) + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned", zshrc: "owned"}) + b.config(t, "app", "font-size", "13") + b.pullIn(t, "app", "desk", app(t)) + b.assign(t, "zsh", zshCollector(t)) + b.pullIn(t, "mesh-cli", "desk", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli\n")) + aClient(t, b, e, t.TempDir(), time.Hour) + + eventually(t, "the pulled-in app.conf is filled with its secret", func() bool { + return e.read(t, appConf) == "token = "+token+"\nfont = 13\n" + }) + eventually(t, "its secret's own file holds it alone", func() bool { return e.read(t, appDB) == password }) + eventually(t, "the pulled-in contributor's item is recorded", func() bool { + got := b.keys(t, CollectBucket, node+".") + return len(got) == 1 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli" + }) + eventually(t, "zshrc holds the pulled-in contributor's item", func() bool { + return e.read(t, zshrc) == "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\n# end\n" + }) + eventually(t, "prov.applied says app applied", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied && len(r.Files) == 2 + }) + // What required it lets it go: its item leaves the collection. + b.del(t, AssignmentsBucket, node+".mesh-cli") + eventually(t, "zshrc loses the item of a module no longer pulled in", func() bool { + return e.read(t, zshrc) == "# zsh\n# then\n# end\n" + }) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go new file mode 100644 index 00000000..b2bba03c --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats.go" +) + +// A module's secrets, from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647). +// +// **Asked for on request and reply, sealed to this client's key.** The client asks the vault's `get` in its own +// name, on the subject that names its account, so the vault knows from the bus's word which machine's client asks; +// it sends the public half of a key it made when it started and keeps in memory alone, and the vault answers the +// secret sealed to it (the sealed box of ADR 0206). Only the box crosses the bus. The value lives in this process +// while it fills the module's files, and reaches the machine only through the node-engine's local write socket. +// It is never written to a bucket, a report, an event or a log. +// +// **Asked once, and again when it changes.** A secret the client holds is asked again when the vault says it +// changed (`secret-changed`: which, by whom, its generation, never a value), so a rotation reaches the file +// without a new declaration; and every time the client starts or its node comes back, when it starts from nothing. +// A secret the vault does not hold yet, or a vault that does not answer, is waited for; a refusal fails the module +// with the vault's reason, and is asked again only after RefusedFor, so a refused client does not fill the vault's +// record of hand-outs every minute. + +// VaultSeat is the vault's seat, and GetVerb the verb a client asks. +const ( + VaultSeat = "mesh-vault" + GetVerb = "get" +) + +// SecretChangedSubject is where the vault says a secret changed (mesh-controller link.EventSubject of mesh-vault's +// `secret-changed`): never a value. +const SecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed" + +// RefusedFor is how long a refusal stands before the vault is asked again. +const RefusedFor = 10 * time.Minute + +// VaultName is a module's own secret on one machine as the vault names it (mesh-vault intake.go OwnSecret). +func VaultName(node, module, name string) string { return "own/" + node + "/" + module + "/" + name } + +// HolderName is the value a provider's software issued for one holder, a module on one machine, as the vault names +// it (mesh-vault perholder.go HolderSecret, issue 749). +func HolderName(provider, provision, node, module string) string { + return "provision/" + provider + "/" + provision + "/" + node + "/" + module +} + +// callerToken is a bus user's name as the last token of a call it publishes, each dot written `~` (mesh-controller +// internal/broker CallerToken). +func callerToken(user string) string { return strings.ReplaceAll(user, ".", "~") } + +// GetSubject is where this client asks the vault's `get` in its own name (mesh-controller internal/broker +// CalledSubject of `mesh.seat.mesh-vault.tool.get`). +func GetSubject(user string) string { + return "mesh.seat." + VaultSeat + ".call." + GetVerb + "." + callerToken(user) +} + +// errNotYet is a secret the vault cannot hand yet: it holds none of that name, or it does not answer. +var errNotYet = errors.New("not yet") + +// Secrets hands a module's secret, opened. +type Secrets interface { + // Get is the secret's value and generation, errNotYet (wrapped, with why) when it is to be waited for, or why + // it was refused. + Get(ctx context.Context, name string) (value string, generation int, err error) +} + +// VaultSecrets asks the vault over the bus. +type VaultSecrets struct { + Conn *nats.Conn + // User is the client's own bus account, `provisioner.`. + User string + // Key is the client's own key, made at start and never written down. + Key seal.KeyPair + // Within bounds one ask. + Within time.Duration +} + +// NewVaultSecrets is the vault reached as user, with a key of its own made now. +func NewVaultSecrets(conn *nats.Conn, user string) (*VaultSecrets, error) { + key, err := seal.GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("the client could not make its key: %w", err) + } + return &VaultSecrets{Conn: conn, User: user, Key: key, Within: 15 * time.Second}, nil +} + +// seatAnswer is a seat's answer on the bus (mesh-controller link.Answer). +type seatAnswer struct { + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// vaultAnswer is `get`'s result (mesh-vault vault.go Answer): never a value in the clear. +type vaultAnswer struct { + Name string `json:"name"` + Generation int `json:"generation"` + Sealed seal.Box `json:"sealed"` +} + +// Get asks the vault for name, sealed to the client's key, and opens it. +func (v *VaultSecrets) Get(ctx context.Context, name string) (string, int, error) { + body, err := json.Marshal(map[string]string{"name": name, "public_key": v.Key.PublicKey}) + if err != nil { + return "", 0, err + } + within := v.Within + if within <= 0 { + within = 15 * time.Second + } + asking, cancel := context.WithTimeout(ctx, within) + defer cancel() + msg, err := v.Conn.RequestWithContext(asking, GetSubject(v.User), body) + switch { + case errors.Is(err, nats.ErrNoResponders): + return "", 0, fmt.Errorf("%w: nothing answers the vault's get", errNotYet) + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout): + return "", 0, fmt.Errorf("%w: the vault did not answer within %s", errNotYet, within) + case err != nil: + return "", 0, fmt.Errorf("asking the vault: %w", err) + } + var a seatAnswer + if err := json.Unmarshal(msg.Data, &a); err != nil { + return "", 0, errors.New("the vault answered something that is not a seat's answer") + } + if a.Error != "" { + if strings.Contains(a.Error, "holds no secret named") { + return "", 0, fmt.Errorf("%w: the vault holds no %s yet", errNotYet, name) + } + return "", 0, fmt.Errorf("the vault refused it: %s", a.Error) + } + var got vaultAnswer + if err := json.Unmarshal(a.Result, &got); err != nil || got.Name != name { + return "", 0, fmt.Errorf("the vault answered something that is not %s", name) + } + value, err := seal.Open(got.Sealed, v.Key.PrivateKey) + if err != nil { + return "", 0, fmt.Errorf("the vault's answer for %s does not open with the client's key: %w", name, err) + } + return value, got.Generation, nil +} + +// heldSecret is a secret as one run of the client holds it: its value, or why it was refused and when. +type heldSecret struct { + value string + generation int + refused string + refusedAt time.Time +} + +// changedSecret is what `secret-changed` says, as far as the client reads it. +type changedSecret struct { + Name string `json:"name"` + Generation int `json:"generation"` +} + +// secretOf is a module's secret, from what the run holds or from the vault: its value, or what it waits for, or +// why it cannot be filled. +func (c *Client) secretOf(ctx context.Context, s *state, module string, p Plan, n Need) (value, wait, fail string) { + name := "" + if slices.ContainsFunc(p.Secrets, func(sec PlannedSecret) bool { return sec.Name == n.Name }) { + name = VaultName(c.Node, module, n.Name) + } else if i := slices.IndexFunc(p.Provisions, func(pr PlannedProvision) bool { return pr.Name == n.Name }); i >= 0 { + // A credential of what the module requires, issued for this module on this machine (issue 757). + pr := p.Provisions[i] + name = HolderName(pr.Provider, pr.Provision, c.Node, module) + if pr.CredentialForm == "pair" { + // This consumer's own login from a provider that gives each consumer one (issue 779, ADR 0158 §11). + name = PairName(pr.Provider, pr.Provision, c.Node, module, pr.Local) + } + } else { + return "", "", fmt.Sprintf("%s: the plan names no secret %s, and the vault hands only what this machine's "+ + "declaration names", n, n.Name) + } + return c.vaultValue(ctx, s, n, name) +} + +// vaultValue is the vault's secret name for the need n, from what the run holds or from the vault: its value, or what +// it waits for, or why it cannot be filled. +func (c *Client) vaultValue(ctx context.Context, s *state, n Need, name string) (value, wait, fail string) { + if c.Secrets == nil { + return "", "", n.String() + ": this client has no way to ask the vault" + } + if h, ok := s.secrets[name]; ok { + if h.refused == "" { + return h.value, "", "" + } + if c.now().Sub(h.refusedAt) < RefusedFor { + return "", "", fmt.Sprintf("%s: %s", n, h.refused) + } + } + got, generation, err := c.Secrets.Get(ctx, name) + switch { + case errors.Is(err, errNotYet): + delete(s.secrets, name) + return "", fmt.Sprintf("%s (%s)", n, strings.TrimPrefix(err.Error(), errNotYet.Error()+": ")), "" + case err != nil: + s.secrets[name] = heldSecret{refused: err.Error(), refusedAt: c.now()} + return "", "", fmt.Sprintf("%s: %v", n, err) + } + s.secrets[name] = heldSecret{value: got, generation: generation} + return got, "", "" +} + +// secretChanged folds what the vault said changed into the state: a secret this run holds is asked again. It +// answers the module whose secret it is, or nothing. +func (c *Client) secretChanged(s *state, body []byte) []string { + var ch changedSecret + if err := json.Unmarshal(body, &ch); err != nil { + return nil + } + parts := strings.Split(ch.Name, "/") + var module string + switch { + case len(parts) == 4 && parts[0] == "own" && parts[1] == c.Node: + module = parts[2] + case len(parts) == 5 && parts[0] == "provision" && parts[3] == c.Node: + // A value issued for one of this machine's modules (issue 757). + module = parts[4] + case (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" && (parts[1] == c.Node || parts[3] == c.Node): + // A consumer's own credential (ADR 0373): from a provider on this machine, every provider here listing its + // consumers is filled again, which asks for it if it lists that consumer, held or waited for; and for a module + // on this machine, the module whose plan names it as its file's credential (issue 779). + if h, held := s.secrets[ch.Name]; held && h.refused == "" && h.generation >= ch.Generation { + return nil + } + delete(s.secrets, ch.Name) + var out []string + if parts[1] == c.Node { + out = sortedKeys(s.providing) + } + if parts[3] == c.Node && hasPlan(s, parts[4]) && !slices.Contains(out, parts[4]) { + out = append(out, parts[4]) + } + return out + default: + return nil + } + if !hasPlan(s, module) { + return nil + } + h, held := s.secrets[ch.Name] + if held && h.refused == "" && h.generation >= ch.Generation { + return nil + } + delete(s.secrets, ch.Name) + return []string{module} +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go new file mode 100644 index 00000000..d2d2dda3 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go @@ -0,0 +1,511 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats.go" +) + +// A node's secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647), against a +// real bus whose accounts are the client's as mesh-controller composes them: a moved module's file is filled with +// its secret from the vault's get, asked in the client's own name and answered sealed to its key; the value never +// crosses the bus in the clear; a secret rotated in the vault reaches its file without a new declaration; a secret +// the vault does not hold yet is waited for, and a refusal fails the module with the vault's reason. +// +// The vault is a stand-in answering `mesh-vault.get` as its seat does on the bus: on the subject naming the caller, +// sealing to the key the caller sends, handing only what it allows that caller (the vault's own tests prove its +// rule), and saying `secret-changed` with no value. + +// standInVault answers get on the bus, for provisioner.g14 alone and only the names it allows. +type standInVault struct { + t *testing.T + conn *nats.Conn + mu sync.Mutex + held map[string]string + gen map[string]int + allowed map[string]bool + asked map[string]int + refuse string +} + +func startVault(t *testing.T, b *aBus) *standInVault { + t.Helper() + v := &standInVault{t: t, conn: b.conn, held: map[string]string{}, gen: map[string]int{}, allowed: map[string]bool{}, + asked: map[string]int{}} + sub, err := b.conn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) { + caller := strings.ReplaceAll(m.Subject[strings.LastIndex(m.Subject, ".")+1:], "~", ".") + var args struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` + } + _ = json.Unmarshal(m.Data, &args) + v.mu.Lock() + defer v.mu.Unlock() + v.asked[args.Name]++ + answer := map[string]any{} + value, held := v.held[args.Name] + switch { + case caller != "provisioner."+node || !v.allowed[args.Name]: + answer["error"] = caller + " may not ask for " + args.Name + ": not on the list" + case v.refuse != "": + answer["error"] = v.refuse + case !held: + answer["error"] = "the vault holds no secret named " + args.Name + default: + box, err := seal.Seal(value, args.PublicKey) + if err != nil { + answer["error"] = err.Error() + break + } + answer["result"] = map[string]any{"name": args.Name, "generation": v.gen[args.Name], + "changed-at": time.Now().UTC(), "sealed": box} + } + body, _ := json.Marshal(answer) + _ = m.Respond(body) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + _ = b.conn.Flush() + return v +} + +// keep holds value as name and allows the client to ask for it; with say, it says secret-changed as the vault does. +func (v *standInVault) keep(name, value string, say bool) { + v.mu.Lock() + v.held[name] = value + v.gen[name]++ + v.allowed[name] = true + gen := v.gen[name] + v.mu.Unlock() + if say { + body, _ := json.Marshal(map[string]any{"name": name, "by": "the test", "at": time.Now().UTC(), "generation": gen}) + if err := v.conn.Publish(SecretChangedSubject, body); err != nil { + v.t.Fatal(err) + } + } +} + +func (v *standInVault) times(name string) int { + v.mu.Lock() + defer v.mu.Unlock() + return v.asked[name] +} + +// sniffer hears every message on the bus, to prove a value never crosses it in the clear. +type sniffer struct { + mu sync.Mutex + seen []string +} + +func sniff(t *testing.T, b *aBus) *sniffer { + t.Helper() + s := &sniffer{} + sub, err := b.conn.Subscribe(">", func(m *nats.Msg) { + s.mu.Lock() + s.seen = append(s.seen, m.Subject+" "+string(m.Data)) + s.mu.Unlock() + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + _ = b.conn.Flush() + return s +} + +func (s *sniffer) carrying(value string) []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, m := range s.seen { + if strings.Contains(m, value) { + out = append(out, m) + } + } + return out +} + +func (s *sniffer) heard() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.seen) +} + +const appConf, appDB = "/etc/app/app.conf", "/var/lib/app/db-password" + +// app is a moved module with a template naming a secret and a config value, and a secret kept in a file of its own. +func app(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\nfont = ${config:font-size}\n"}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", + OwnerAtRuntime: "owned", Takes: "restart-on"}}, + Secrets: []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}} +} + +func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + wire := sniff(t, b) + vault := startVault(t, b) + const token, password = "tok-3f9a1c-never-on-the-bus", "pw-77b2e0-never-on-the-bus" + vault.keep("own/g14/app/app-token", token, false) + vault.keep("own/g14/app/db-password", password, false) + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + eventually(t, "app.conf is filled with its secret", func() bool { + return e.read(t, appConf) == "token = "+token+"\nfont = 13\n" + }) + eventually(t, "the secret's own file holds it alone", func() bool { return e.read(t, appDB) == password }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied && len(r.Files) == 2 + }) + eventually(t, "the needs name the secrets, never their values", func() bool { + got := b.keys(t, NeedsBucket, node+".app.") + return strings.Join(got, " ") == "g14.app.config.font-size g14.app.secret.app-token g14.app.secret.db-password" + }) + if wire.heard() == 0 { + t.Fatal("the sniffer heard nothing: it proves nothing") + } + for _, value := range []string{token, password} { + if said := wire.carrying(value); len(said) > 0 { + t.Errorf("the value crossed the bus in the clear: %q", said) + } + } + if vault.times("own/g14/app/app-token") != 1 || vault.times("own/g14/app/db-password") != 1 { + t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/g14/app/app-token"), + vault.times("own/g14/app/db-password")) + } +} + +func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + vault := startVault(t, b) + vault.keep("own/g14/app/app-token", "first", false) + vault.keep("own/g14/app/db-password", "pw", false) + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "app.conf is filled", func() bool { return strings.Contains(e.read(t, appConf), "token = first\n") }) + assigned, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app") + if err != nil { + t.Fatal(err) + } + + vault.keep("own/g14/app/app-token", "second", true) + eventually(t, "the rotated value reaches its file", func() bool { + return strings.Contains(e.read(t, appConf), "token = second\n") + }) + eventually(t, "prov.applied says it was updated", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied && r.Files[0].Action == "updated" + }) + now, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app") + if err != nil || now.Revision() != assigned.Revision() { + t.Errorf("the module's assignment was written again: revision %d, was %d", now.Revision(), assigned.Revision()) + } + if moved, _ := e.counts(appConf); moved != 2 { + t.Errorf("app.conf moved %d times, want 2: the program restarts once for the rotation", moved) + } + if moved, _ := e.counts(appDB); moved != 1 { + t.Errorf("the other secret's file moved %d times: only what changed is written", moved) + } + // What another machine's secret, or a secret no plan here names, changes is no reason to ask. + vault.keep("own/ace/app/app-token", "elsewhere", true) + vault.keep("own/g14/other/token", "unplanned", true) + time.Sleep(300 * time.Millisecond) + if n := vault.times("own/g14/app/app-token"); n != 2 { + t.Errorf("the vault was asked for app-token %d times, want 2", n) + } + if vault.times("own/ace/app/app-token")+vault.times("own/g14/other/token") != 0 { + t.Error("a change of a secret this machine's plans do not name made the client ask") + } +} + +func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + vault := startVault(t, b) + vault.keep("own/g14/app/db-password", "pw", false) + vault.allowed["own/g14/app/app-token"] = true // the plan names it; the vault does not hold it yet + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + eventually(t, "prov.applied says it waits for the secret", func() bool { + r, _ := heard.last("app") + return r.Status == StatusWaiting && len(r.WaitsFor) == 1 && + strings.HasPrefix(r.WaitsFor[0], "secret:app-token (the vault holds no") + }) + if _, asked := e.counts(appConf); asked != 0 { + t.Error("a file waiting for its secret was written") + } + if _, asked := e.counts(appDB); asked != 0 { + t.Error("a module waiting for one value had another file written: every value or none") + } + vault.keep("own/g14/app/app-token", "arrived", true) + eventually(t, "it is filled once the vault holds it", func() bool { + return strings.Contains(e.read(t, appConf), "token = arrived\n") && e.read(t, appDB) == "pw" + }) +} + +func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + vault := startVault(t, b) + vault.keep("own/g14/app/app-token", "tok", false) + vault.keep("own/g14/app/db-password", "pw", false) + vault.refuse = "provisioner.g14 may not ask for own/g14/app/app-token: app's plan on g14 names no secret app-token" + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + aClient(t, b, e, t.TempDir(), 100*time.Millisecond) + + eventually(t, "prov.applied says it failed, with the vault's reason", func() bool { + r, _ := heard.last("app") + return r.Status == StatusFailed && strings.Contains(r.Reason, "names no secret app-token") + }) + time.Sleep(600 * time.Millisecond) + if n := vault.times("own/g14/app/app-token"); n != 1 { + t.Errorf("a refused secret was asked %d times in six drift passes, want once", n) + } + if _, asked := e.counts(appConf); asked != 0 { + t.Error("a file whose secret was refused was written") + } +} + +func TestNatsAVaultThatDoesNotAnswerIsWaitedFor(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "prov.applied says it waits for the vault", func() bool { + r, _ := heard.last("app") + return r.Status == StatusWaiting && len(r.WaitsFor) == 2 && strings.Contains(r.WaitsFor[0], "nothing answers") + }) +} + +// The plan's secrets are read strictly: a name that is not a secret's, one named twice, and a file path that is not +// clean and absolute, or a template's, each refuse the plan. +func TestAPlansSecretsAreReadStrictly(t *testing.T) { + base := func() Plan { + return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)}, + Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}}} + } + for why, secrets := range map[string][]PlannedSecret{ + "a name that is not a secret's": {{Name: "App Token"}}, + "a name given twice": {{Name: "t"}, {Name: "t"}}, + "a relative path": {{Name: "t", Path: "etc/t"}}, + "a template's path": {{Name: "t", Path: appConf}}, + } { + p := base() + p.Secrets = secrets + if len(p.problems()) == 0 { + t.Errorf("a plan with %s was taken", why) + } + } + p := base() + p.Secrets = []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}} + if wrong := p.problems(); len(wrong) > 0 { + t.Errorf("a good plan was refused: %v", wrong) + } +} + +// The forge's read credential for claude-code on this machine (issue 757, issue 737): git's credential-store line +// the forge's software issued for this holder alone, kept by the vault as +// provision//gitea-read//claude-code. +const ( + forgeFile = "/var/lib/claude-code/state/gitea-read.secret" + settingsFile = "/etc/claude-code/managed-settings.json" + forgeHolder = "provision/novox/gitea-read/" + node + "/claude-code" +) + +// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from novox, kept in a file of +// its own and named in a template. +func claudeCode(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"managed-settings.json": `{"forge":"${secret:gitea-read}"}`}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "settings", Path: settingsFile, + Template: "managed-settings.json", OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", + Path: forgeFile}}} +} + +func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollowsARotation(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + wire := sniff(t, b) + vault := startVault(t, b) + const first = "https://agent-g14:gta-first-never-on-the-bus@forge.example" + const second = "https://agent-g14:gta-second-never-on-the-bus@forge.example" + vault.keep(forgeHolder, first, false) + e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"}) + b.assign(t, "claude-code", claudeCode(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + eventually(t, "gitea-read's file holds the value issued for claude-code here, alone", func() bool { + return e.read(t, forgeFile) == first + }) + eventually(t, "the template naming it is filled with it", func() bool { + return e.read(t, settingsFile) == `{"forge":"`+first+`"}` + }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("claude-code") + return r.Status == StatusApplied && len(r.Files) == 2 + }) + vault.keep(forgeHolder, second, true) + eventually(t, "a value the forge issued anew reaches the file without a new declaration", func() bool { + return e.read(t, forgeFile) == second + }) + // Another machine's or another module's holder value changing is no reason to ask. + vault.keep("provision/novox/gitea-read/ace/claude-code", "elsewhere", true) + vault.keep("provision/novox/gitea-read/"+node+"/zsh", "unplanned", true) + time.Sleep(300 * time.Millisecond) + if n := vault.times(forgeHolder); n != 2 { + t.Errorf("the vault was asked for the holder value %d times, want 2", n) + } + if vault.times("provision/novox/gitea-read/ace/claude-code")+vault.times("provision/novox/gitea-read/"+node+"/zsh") != 0 { + t.Error("a change of a value no plan here names made the client ask") + } + if wire.heard() == 0 { + t.Fatal("the sniffer heard nothing: it proves nothing") + } + for _, value := range []string{first, second, "gta-first-never-on-the-bus", "gta-second-never-on-the-bus"} { + if said := wire.carrying(value); len(said) > 0 { + t.Errorf("the value crossed the bus in the clear: %q", said) + } + } +} + +// A required credential the vault refuses fails the module with the vault's reason, and nothing is written. +func TestNatsARequiredCredentialTheVaultRefusesFailsTheModule(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + vault := startVault(t, b) + vault.keep(forgeHolder, "https://agent-g14:tok@forge.example", false) + vault.refuse = "provisioner.g14 may not ask for " + forgeHolder + ": claude-code's plan on g14 names no credential of gitea-read from novox" + e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"}) + b.assign(t, "claude-code", claudeCode(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + eventually(t, "prov.applied says it failed, with the vault's reason", func() bool { + r, _ := heard.last("claude-code") + return r.Status == StatusFailed && strings.Contains(r.Reason, "names no credential of gitea-read") + }) + if _, asked := e.counts(forgeFile); asked != 0 { + t.Error("a file whose credential was refused was written") + } +} + +// The plan's provisions are read strictly. +func TestAPlansProvisionsAreReadStrictly(t *testing.T) { + base := func() Plan { + return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)}, + Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}}, + Secrets: []PlannedSecret{{Name: "own"}}} + } + good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: forgeFile} + for why, change := range map[string]func(*PlannedProvision){ + "a name that is not a secret's": func(p *PlannedProvision) { p.Name = "Gitea Read" }, + "a name an own secret has": func(p *PlannedProvision) { p.Name = "own" }, + "no provision": func(p *PlannedProvision) { p.Provision = "" }, + "a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../ace" }, + "no provider": func(p *PlannedProvision) { p.Provider = "" }, + "a relative path": func(p *PlannedProvision) { p.Path = "var/x" }, + "a template's path": func(p *PlannedProvision) { p.Path = appConf }, + } { + p := base() + pr := good + change(&pr) + p.Provisions = []PlannedProvision{pr} + if len(p.problems()) == 0 { + t.Errorf("a plan with %s was taken", why) + } + } + p := base() + p.Provisions = []PlannedProvision{good} + if wrong := p.problems(); len(wrong) > 0 { + t.Errorf("a good plan was refused: %v", wrong) + } + p.Provisions = append(p.Provisions, good) + if len(p.problems()) == 0 { + t.Error("a plan naming one credential twice was taken") + } +} + +const ( + dbFile = "/var/lib/nextcloud/state/database.secret" + dbConf = "/etc/nextcloud/config.php" + dbHolder = "pair/novox/postgres-database/" + node + "/nextcloud" +) + +func nextcloud(t *testing.T) Plan { + tpl, _ := templates(t, map[string]string{"config.php": `dbpass=${secret:database}`}) + return Plan{Templates: tpl, Files: []PlannedFile{{ID: "config", Path: dbConf, Template: "config.php", + OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "novox", + ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}} +} + +// A consumer's own login from a provider that gives each consumer one (issue 779) is the vault's pair row for this +// module on this machine: its secrets file holds it alone, and it is asked for again when the vault says it changed. +func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollowsARotation(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + wire := sniff(t, b) + vault := startVault(t, b) + const first, second = "login-first-never-on-the-bus", "login-second-never-on-the-bus" + vault.keep(dbHolder, first, false) + e := startEngine(t, map[string]string{dbFile: "owned", dbConf: "owned"}) + b.assign(t, "nextcloud", nextcloud(t)) + aClient(t, b, e, t.TempDir(), time.Hour) + + eventually(t, "the secrets file holds the module's own login, alone", func() bool { + return e.read(t, dbFile) == first + }) + eventually(t, "the template naming it is filled with it", func() bool { + return e.read(t, dbConf) == "dbpass="+first + }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("nextcloud") + return r.Status == StatusApplied && len(r.Files) == 2 + }) + vault.keep(dbHolder, second, true) + eventually(t, "a login rotated in the vault reaches the file without a new declaration", func() bool { + return e.read(t, dbFile) == second + }) + // Another consumer's login changing is no reason to ask. + other := "pair/novox/postgres-database/ace/nextcloud" + vault.keep(other, "elsewhere", true) + time.Sleep(300 * time.Millisecond) + if n := vault.times(dbHolder); n != 2 { + t.Errorf("the vault was asked for the login %d times, want 2", n) + } + if vault.times(other) != 0 { + t.Error("a change of another consumer's login made the client ask") + } + for _, value := range []string{first, second} { + if said := wire.carrying(value); len(said) > 0 { + t.Errorf("the login crossed the bus in the clear: %q", said) + } + } +} + +func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) { + if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" { + t.Errorf("a login under a local name: %s", got) + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/template.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/template.go new file mode 100644 index 00000000..1e3f3033 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/template.go @@ -0,0 +1,194 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "time" +) + +// A module's templates, fetched by digest (novox/hq ADR 0349 clause 2, design 57 P5): the archive its build packed +// its templates directory into, fetched from where the plan says and used only when it hashes to the plan's +// digest — the bytes come from a network the mesh does not control, and only the digest makes them the ones the +// mesh built. Kept in the client's state by digest, so a node that cannot reach the artifact store still fills +// from what it fetched. + +// maxTemplates bounds a templates archive: configuration text, never a theme. +const maxTemplates = 32 << 20 + +// Fetcher fetches an archive's bytes from its source. +type Fetcher func(ctx context.Context, source string) ([]byte, error) + +// HTTPFetch is the artifact store, over HTTP. +func HTTPFetch(ctx context.Context, source string) ([]byte, error) { + if !strings.HasPrefix(source, "https://") && !strings.HasPrefix(source, "http://") { + return nil, fmt.Errorf("%s is not an address the templates can be fetched from", source) + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch %s: %w", source, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching %s: %s", source, resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTemplates+1)) + if err != nil { + return nil, err + } + if len(body) > maxTemplates { + return nil, fmt.Errorf("%s is over %d bytes, more than a module's templates", source, maxTemplates) + } + return body, nil +} + +// templateStore keeps fetched archives by digest under dir, and their texts in memory. +type templateStore struct { + dir string + fetch Fetcher + texts map[string]map[string]string +} + +// load is every template's text of the archive the plan names, by its path inside the archive. +func (s *templateStore) load(ctx context.Context, t Templates) (map[string]string, error) { + if texts, ok := s.texts[t.Digest]; ok { + return texts, nil + } + kept := filepath.Join(s.dir, strings.TrimPrefix(t.Digest, "sha256:")+".tar.gz") + body, err := os.ReadFile(kept) + if err != nil || digestOf(body) != t.Digest { + if body, err = s.fetch(ctx, t.Source); err != nil { + return nil, err + } + if got := digestOf(body); got != t.Digest { + return nil, fmt.Errorf("the templates at %s are %s, and the plan names %s: nothing was filled from them", + t.Source, got, t.Digest) + } + if err := writeKept(kept, body); err != nil { + // Said and gone on: the archive is in hand, and is fetched again next start. + logf("[provisioner-client] the templates %s could not be kept at %s: %v", t.Digest, kept, err) + } + } + texts, err := unpackTexts(body) + if err != nil { + return nil, fmt.Errorf("the templates %s cannot be read: %w", t.Digest, err) + } + if s.texts == nil { + s.texts = map[string]map[string]string{} + } + s.texts[t.Digest] = texts + return texts, nil +} + +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func writeKept(at string, body []byte) error { + if err := os.MkdirAll(filepath.Dir(at), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(at), ".templates-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(body); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), at) +} + +// unpackTexts is every regular file of a gzipped tar, by its clean path inside it. +func unpackTexts(body []byte) (map[string]string, error) { + zipped, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer zipped.Close() + r := tar.NewReader(zipped) + out := map[string]string{} + for { + h, err := r.Next() + if errors.Is(err, io.EOF) { + return out, nil + } + if err != nil { + return nil, err + } + if h.Typeflag != tar.TypeReg { + continue + } + b, err := io.ReadAll(io.LimitReader(r, maxTemplates)) + if err != nil { + return nil, err + } + out[strings.TrimPrefix(path.Clean(h.Name), "./")] = string(b) + } +} + +// placeholder is `${:}`, as ADR 0349 clause 2 writes one. +var placeholder = regexp.MustCompile(`\$\{([a-z][a-z0-9-]*):([^}]+)\}`) + +// Need is one value a template names. +type Need struct { + Kind string + Name string +} + +func (n Need) String() string { return n.Kind + ":" + n.Name } + +// needsOf is every placeholder a text names, in the order each first appears. +func needsOf(text string) []Need { + var out []Need + seen := map[Need]bool{} + for _, m := range placeholder.FindAllStringSubmatch(text, -1) { + n := Need{Kind: m[1], Name: m[2]} + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out +} + +// render fills every placeholder of text from values, which holds each. +func render(text string, values map[Need]string) string { + return placeholder.ReplaceAllStringFunc(text, func(ref string) string { + m := placeholder.FindStringSubmatch(ref) + return values[Need{Kind: m[1], Name: m[2]}] + }) +} + +// configText is a config value as a file takes it: a string as its text, anything else as its JSON. +func configText(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return string(raw) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf new file mode 100644 index 00000000..85e77ee0 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf @@ -0,0 +1,25 @@ +# The provisioner client's account as mesh-controller composes it (internal/broker ComposeAccounts, feature branch +# 57-config-and-provisioning with issue 647, which grants it the vault's secret-changed, issue 685, which lets its +# own machine's tool runner ask it, and issue 679, which lets it read the whole assignments mirror and its machine's +# keys of a provider's bucket of entries, here postgres_bound, and for gitea's route, route-adapter_bound and route-proxy_bound, issue 792; node-tools_bound for claude-code's console port, issue 761), the tool runner's account of the same machine +# carrying claude-code, and a `domain` user granted everything, standing for the controller, mesh-config and the +# vault, which own the buckets the client reads and answer its asks. Composed again if the client's grants change. + +accounts { + MESH { + jetstream: enabled + users = [ + { user: "domain", password: "x" } + { user: "provisioner.g14", password: "x", permissions: { + publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_needs.*.$KV.mesh-provisioner_needs.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.g14.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_needs.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", "$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_needs.$KV.mesh-provisioner_needs.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.g14.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.g14.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-controller_assignments", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_needs", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-controller_assignments.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_needs.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.g14.>", "$KV.mesh-provisioner_collect.g14.>", "$KV.mesh-provisioner_needs.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14", "prov.applied.g14.>", "prov.changed.g14.>"] } + subscribe: { allow: ["_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "g14.node-tools", password: "x", permissions: { + publish: { allow: ["$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.g14.*", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "mesh.control.g14.tools-alive", "mesh.mod.*.call.*.*.g14~node-tools", "mesh.mod.*.call.*.g14~node-tools", "mesh.mod.*.tool.>", "mesh.seat.*.call.*.*.g14~node-tools", "mesh.seat.*.call.*.g14~node-tools", "mesh.seat.*.tool.>", "prov.ask.g14"], deny: ["mesh.seat.node-launcher.tool.secret", "mesh.seat.node-launcher.tool.secret.*", "mesh.mod.*.tool.node-launcher.secret", "mesh.mod.*.tool.node-launcher.secret.*", "mesh.seat.node-launcher.call.secret.>", "mesh.mod.*.call.node-launcher.secret.>"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.claude-code", "$SRV.INFO.claude-code.>", "$SRV.INFO.node-tools", "$SRV.INFO.node-tools.>", "$SRV.PING", "$SRV.PING.claude-code", "$SRV.PING.claude-code.>", "$SRV.PING.node-tools", "$SRV.PING.node-tools.>", "$SRV.STATS", "$SRV.STATS.claude-code", "$SRV.STATS.claude-code.>", "$SRV.STATS.node-tools", "$SRV.STATS.node-tools.>", "_INBOX.g14.node-tools.>", "mesh.assignment.g14.*", "mesh.mod.claude-code.call.>", "mesh.mod.claude-code.tool.>", "prov.changed.g14.>"] } + allow_responses: { max: 1, ttl: "1m" } + } } + ] + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.bottom-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.bottom-bar.toml new file mode 100644 index 00000000..d5ec4c7c --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.bottom-bar.toml @@ -0,0 +1,119 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. + +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +# power +[[block]] +block = "battery" +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" + + +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.top-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.top-bar.toml new file mode 100644 index 00000000..70714fd9 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/battery.top-bar.toml @@ -0,0 +1,36 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.bottom-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.bottom-bar.toml new file mode 100644 index 00000000..ad3a4e65 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.bottom-bar.toml @@ -0,0 +1,126 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. + +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +# power +[[block]] +block = "battery" +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" + +# power +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw workstation" +interval = 5 +hide_when_empty = true + + +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.top-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.top-bar.toml new file mode 100644 index 00000000..70714fd9 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/both.top-bar.toml @@ -0,0 +1,36 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.bottom-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.bottom-bar.toml new file mode 100644 index 00000000..59dc87a1 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.bottom-bar.toml @@ -0,0 +1,109 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. + +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. + +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.top-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.top-bar.toml new file mode 100644 index 00000000..70714fd9 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/none.top-bar.toml @@ -0,0 +1,36 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.bottom-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.bottom-bar.toml new file mode 100644 index 00000000..ae6c2ce2 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.bottom-bar.toml @@ -0,0 +1,116 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. + +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +# power +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw workstation" +interval = 5 +hide_when_empty = true + + +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.top-bar.toml b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.top-bar.toml new file mode 100644 index 00000000..70714fd9 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/bars/power-meter.top-bar.toml @@ -0,0 +1,36 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.posix b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.posix new file mode 100644 index 00000000..aa61849d --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.posix @@ -0,0 +1,26 @@ +# adwaita +export GTK2_RC_FILES='/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc' +export GTK_THEME='Adwaita:dark' +export QT_QPA_PLATFORMTHEME='qt6ct' +export QT_SELECT='6' +export QT_STYLE_OVERRIDE='Fusion' +export XCURSOR_SIZE='24' +export XCURSOR_THEME='Adwaita' +# clipmenu +export CM_HISTLENGTH='15' +export CM_MAX_CLIPS='500' +export CM_SELECTIONS='clipboard' +# i3 +export XDG_CURRENT_DESKTOP='i3' +export XDG_SESSION_DESKTOP='i3' +# xterm +export TERMINAL='xterm' +# zsh +export EDITOR='vim' +export VISUAL='vim' +export XDG_CONFIG_HOME='/home/op/.config' +# zsh +case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac +export PATH diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.systemd b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.systemd new file mode 100644 index 00000000..70e7709a --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/all.systemd @@ -0,0 +1,23 @@ +# adwaita +GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc +GTK_THEME=Adwaita:dark +QT_QPA_PLATFORMTHEME=qt6ct +QT_SELECT=6 +QT_STYLE_OVERRIDE=Fusion +XCURSOR_SIZE=24 +XCURSOR_THEME=Adwaita +# clipmenu +CM_HISTLENGTH=15 +CM_MAX_CLIPS=500 +CM_SELECTIONS=clipboard +# i3 +XDG_CURRENT_DESKTOP=i3 +XDG_SESSION_DESKTOP=i3 +# xterm +TERMINAL=xterm +# zsh +EDITOR=vim +VISUAL=vim +XDG_CONFIG_HOME=/home/op/.config +# zsh +PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.posix b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.posix new file mode 100644 index 00000000..905c1574 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.posix @@ -0,0 +1,9 @@ +# zsh +export EDITOR='vim' +export VISUAL='vim' +export XDG_CONFIG_HOME='/home/op/.config' +# zsh +case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac +export PATH diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.systemd b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.systemd new file mode 100644 index 00000000..2a28ce0a --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/environment/zsh.systemd @@ -0,0 +1,6 @@ +# zsh +EDITOR=vim +VISUAL=vim +XDG_CONFIG_HOME=/home/op/.config +# zsh +PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/all.config b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/all.config new file mode 100644 index 00000000..6475ac58 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/all.config @@ -0,0 +1,310 @@ +# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; +# change the module instead. i3's user guide is the reference. +# +# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR +# 0212): the mesh places their lines near the end, each module's under a line naming it, where every +# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read +# after them, by the include at the very end, and is yours. +# +# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the +# result with i3 -C; it never reloads into a configuration with errors. + +# Font for window titles, and the bars below: the mesh's monospace face (research 026/04). +font pango:JetBrainsMono Nerd Font 11 + +# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login. +exec --no-startup-id dex --autostart --environment i3 + +######################################### +###### Keys #### +######################################### +# To find key symbols: xmodmap -pke / xmodmap -pm +set $mod Mod4 +set $alt Mod1 +set $shift Shift +set $ctrl Control + +# use these keys for focus, movement, and resize directions when reaching for +# the arrows is not convenient +set $left h +set $down j +set $up k +set $right l + +# use Mouse+$mod to drag floating windows to their wanted position +floating_modifier $mod + +# move tiling windows via drag & drop by left-clicking into the title bar, +# or left-clicking anywhere into the window while holding the floating modifier. +tiling_drag modifier titlebar + +# start a terminal: whichever the terminal module names in $TERMINAL +bindsym $mod+Return exec i3-sensible-terminal + +# kill focused window +bindsym $mod+$shift+q kill + +# change focus +bindsym $mod+$left focus left +bindsym $mod+$down focus down +bindsym $mod+$up focus up +bindsym $mod+$right focus right + +bindsym $mod+Left focus left +bindsym $mod+Down focus down +bindsym $mod+Up focus up +bindsym $mod+Right focus right + +# move focused window +bindsym $mod+$shift+$left move left +bindsym $mod+$shift+$down move down +bindsym $mod+$shift+$up move up +bindsym $mod+$shift+$right move right + +bindsym $mod+$shift+Left move left +bindsym $mod+$shift+Down move down +bindsym $mod+$shift+Up move up +bindsym $mod+$shift+Right move right + +# split in horizontal orientation +bindsym $mod+c split h +# split in vertical orientation +bindsym $mod+v split v + +# enter fullscreen mode for the focused container +bindsym $mod+f fullscreen toggle + +# change container layout (stacked, tabbed, toggle split) +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +bindsym $mod+e layout toggle split + +# toggle tiling / floating +bindsym $mod+$shift+space floating toggle + +# change focus between tiling / floating windows +bindsym $mod+space focus mode_toggle + +# focus the parent container +bindsym $mod+a focus parent + +# alt-tab functionality +bindsym $mod+Tab workspace back_and_forth + +######################################### +###### Workspace mgmt #### +######################################### +set $ws1 "1" +set $ws2 "2" +set $ws3 "3" +set $ws4 "4" +set $ws5 "5" +set $ws6 "6" +set $ws7 "7" +set $ws8 "8" +set $ws9 "9" +set $ws10 "10" + +bindsym $mod+1 workspace number $ws1 +bindsym $mod+2 workspace number $ws2 +bindsym $mod+3 workspace number $ws3 +bindsym $mod+4 workspace number $ws4 +bindsym $mod+5 workspace number $ws5 +bindsym $mod+6 workspace number $ws6 +bindsym $mod+7 workspace number $ws7 +bindsym $mod+8 workspace number $ws8 +bindsym $mod+9 workspace number $ws9 +bindsym $mod+0 workspace number $ws10 + +bindsym $mod+$shift+1 move container to workspace number $ws1 +bindsym $mod+$shift+2 move container to workspace number $ws2 +bindsym $mod+$shift+3 move container to workspace number $ws3 +bindsym $mod+$shift+4 move container to workspace number $ws4 +bindsym $mod+$shift+5 move container to workspace number $ws5 +bindsym $mod+$shift+6 move container to workspace number $ws6 +bindsym $mod+$shift+7 move container to workspace number $ws7 +bindsym $mod+$shift+8 move container to workspace number $ws8 +bindsym $mod+$shift+9 move container to workspace number $ws9 +bindsym $mod+$shift+0 move container to workspace number $ws10 + +######################################### +###### Window mgmt #### +######################################### +set $resize_px 10 px +bindsym $mod+$ctrl+$left resize shrink width $resize_px +bindsym $mod+$ctrl+$down resize grow height $resize_px +bindsym $mod+$ctrl+$up resize shrink height $resize_px +bindsym $mod+$ctrl+$right resize grow width $resize_px + +######################################### +###### Session mgmt #### +######################################### +bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'" + +# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and +# suspend and lid close too). +bindsym $mod+Delete exec --no-startup-id loginctl lock-session + +# reload the configuration file +bindsym $mod+$shift+c reload + +# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) +bindsym $mod+$shift+r restart + +######################################### +###### Borders #### +######################################### +default_border pixel 1 +smart_borders on + +######################################### +###### Gaps #### +######################################### +gaps inner 0 +gaps outer 0 + +# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are +# `pixel`, so no title bar shows: the colour says which window has focus. +# border background text indicator child_border +client.focused #de5200 #de5200 #1E2127 #de5200 #de5200 +client.urgent #900000 #900000 #ffffff #900000 #900000 + +######################################### +###### Until their modules carry them ## +######################################### +# Each line below belongs to something other than i3, named on its line. When that module is written +# it contributes the line to node-display-session, and the line goes from here in the same change. +# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals' +# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14, +# polychromatic). + +# the operator's scripts: volume, games volume, sessions, screenshot +bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up +bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down +bindsym XF86AudioMute exec --no-startup-id volume-notify mute +bindsym XF86AudioMicMute exec --no-startup-id mic-notify +bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5 +bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5 +bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh +bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +######################################### +###### Other modules' lines #### +######################################### +# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the +# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's +# under a line naming it. +# asus-zephyrus-g14 +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are +# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for +# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's. + +# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the +# screenshot grabs the pointer to select a region, which fails while the key is still held. +bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the +# external output. +bindsym $mod+p exec --no-startup-id @ASUS_LIB@/bin/zephyrus-display order + +# The keyboard backlight's level, shown when it changes. +exec --no-startup-id @ASUS_LIB@/bin/zephyrus-kbd-notify +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The model's panel and touchpad. + +# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where +# is the display server's (autorandr, run at every session start). +exec --no-startup-id @ASUS_LIB@/bin/zephyrus-display primary + +# The touchpad's settings again, by hand. X applies them itself whenever the device appears. +bindsym $mod+Shift+x exec --no-startup-id @ASUS_LIB@/bin/zephyrus-touchpad reset +# clipmenu +# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at +# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which +# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the +# clipboard. +bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard +# dunst +# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is +# chosen from the keyboard as a tap chooses it on the notification. +bindsym $mod+n exec --no-startup-id dunstctl context +# feh +# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session. +bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg +# i3status-rust +# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom +# bar shows the machine; the top bar the focused window and the tray, on the primary output. The +# face is the monospace one every desktop module names. +bar { + font pango:JetBrainsMono Nerd Font 11 + position bottom + status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml + tray_output none + colors { + separator #ffffff + background #000000 + statusline #ffffff + # The text on an accent-coloured button is dark: light text on the accent was barely + # legible. + focused_workspace #de5200 #de5200 #000000 + active_workspace #de5200 #de5200 #000000 + inactive_workspace #000000 #000000 #ffffff + urgent_workspace #2f343a #900000 #ffffff + } +} + +bar { + font pango:JetBrainsMono Nerd Font 11 + position top + status_command i3status-rs ~/.config/i3status-rust/top-bar.toml + workspace_buttons no + tray_output primary + colors { + separator #ffffff + background #000000 + statusline #ffffff + } +} +# polychromatic +# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh: +# replaced at every push. This line is the tray's one start, at the session's start (exec, not +# exec_always, so a reload starts nothing). The package's own login helper starts it too while the +# application's setting "Start the tray applet when I log on" is ticked; polychromatic_check names +# that as a second start. +exec --no-startup-id polychromatic-tray-applet +# rofi +# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after +# the variables it sets, so $mod is i3's. +bindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun +bindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run +bindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo +bindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window +bindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu +# slack +# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push. +# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry. +# No XDG autostart entry starts it as well; slack_check names one if it appears. +exec --no-startup-id /usr/bin/slack --gtk-version=3 -s +# The chat window. Slack owns four X windows, and the only one i3 manages has the class "slack" in +# lower case; the three of class "Slack" (the packaged entry's StartupWMClass) are its unmanaged +# helpers and its tray icon, so a rule on class="Slack" matches nothing. window_role keeps a call or +# screen-share window out of the rules. $ws3 is i3's, in scope here. +for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3 +for_window [class="(?i)^slack$" window_role="browser-window"] floating disable +for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2 + +######################################### +###### Your own files #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/i3-only.config b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/i3-only.config new file mode 100644 index 00000000..12751ffa --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/i3/i3-only.config @@ -0,0 +1,201 @@ +# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; +# change the module instead. i3's user guide is the reference. +# +# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR +# 0212): the mesh places their lines near the end, each module's under a line naming it, where every +# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read +# after them, by the include at the very end, and is yours. +# +# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the +# result with i3 -C; it never reloads into a configuration with errors. + +# Font for window titles, and the bars below: the mesh's monospace face (research 026/04). +font pango:JetBrainsMono Nerd Font 11 + +# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login. +exec --no-startup-id dex --autostart --environment i3 + +######################################### +###### Keys #### +######################################### +# To find key symbols: xmodmap -pke / xmodmap -pm +set $mod Mod4 +set $alt Mod1 +set $shift Shift +set $ctrl Control + +# use these keys for focus, movement, and resize directions when reaching for +# the arrows is not convenient +set $left h +set $down j +set $up k +set $right l + +# use Mouse+$mod to drag floating windows to their wanted position +floating_modifier $mod + +# move tiling windows via drag & drop by left-clicking into the title bar, +# or left-clicking anywhere into the window while holding the floating modifier. +tiling_drag modifier titlebar + +# start a terminal: whichever the terminal module names in $TERMINAL +bindsym $mod+Return exec i3-sensible-terminal + +# kill focused window +bindsym $mod+$shift+q kill + +# change focus +bindsym $mod+$left focus left +bindsym $mod+$down focus down +bindsym $mod+$up focus up +bindsym $mod+$right focus right + +bindsym $mod+Left focus left +bindsym $mod+Down focus down +bindsym $mod+Up focus up +bindsym $mod+Right focus right + +# move focused window +bindsym $mod+$shift+$left move left +bindsym $mod+$shift+$down move down +bindsym $mod+$shift+$up move up +bindsym $mod+$shift+$right move right + +bindsym $mod+$shift+Left move left +bindsym $mod+$shift+Down move down +bindsym $mod+$shift+Up move up +bindsym $mod+$shift+Right move right + +# split in horizontal orientation +bindsym $mod+c split h +# split in vertical orientation +bindsym $mod+v split v + +# enter fullscreen mode for the focused container +bindsym $mod+f fullscreen toggle + +# change container layout (stacked, tabbed, toggle split) +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +bindsym $mod+e layout toggle split + +# toggle tiling / floating +bindsym $mod+$shift+space floating toggle + +# change focus between tiling / floating windows +bindsym $mod+space focus mode_toggle + +# focus the parent container +bindsym $mod+a focus parent + +# alt-tab functionality +bindsym $mod+Tab workspace back_and_forth + +######################################### +###### Workspace mgmt #### +######################################### +set $ws1 "1" +set $ws2 "2" +set $ws3 "3" +set $ws4 "4" +set $ws5 "5" +set $ws6 "6" +set $ws7 "7" +set $ws8 "8" +set $ws9 "9" +set $ws10 "10" + +bindsym $mod+1 workspace number $ws1 +bindsym $mod+2 workspace number $ws2 +bindsym $mod+3 workspace number $ws3 +bindsym $mod+4 workspace number $ws4 +bindsym $mod+5 workspace number $ws5 +bindsym $mod+6 workspace number $ws6 +bindsym $mod+7 workspace number $ws7 +bindsym $mod+8 workspace number $ws8 +bindsym $mod+9 workspace number $ws9 +bindsym $mod+0 workspace number $ws10 + +bindsym $mod+$shift+1 move container to workspace number $ws1 +bindsym $mod+$shift+2 move container to workspace number $ws2 +bindsym $mod+$shift+3 move container to workspace number $ws3 +bindsym $mod+$shift+4 move container to workspace number $ws4 +bindsym $mod+$shift+5 move container to workspace number $ws5 +bindsym $mod+$shift+6 move container to workspace number $ws6 +bindsym $mod+$shift+7 move container to workspace number $ws7 +bindsym $mod+$shift+8 move container to workspace number $ws8 +bindsym $mod+$shift+9 move container to workspace number $ws9 +bindsym $mod+$shift+0 move container to workspace number $ws10 + +######################################### +###### Window mgmt #### +######################################### +set $resize_px 10 px +bindsym $mod+$ctrl+$left resize shrink width $resize_px +bindsym $mod+$ctrl+$down resize grow height $resize_px +bindsym $mod+$ctrl+$up resize shrink height $resize_px +bindsym $mod+$ctrl+$right resize grow width $resize_px + +######################################### +###### Session mgmt #### +######################################### +bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'" + +# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and +# suspend and lid close too). +bindsym $mod+Delete exec --no-startup-id loginctl lock-session + +# reload the configuration file +bindsym $mod+$shift+c reload + +# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) +bindsym $mod+$shift+r restart + +######################################### +###### Borders #### +######################################### +default_border pixel 1 +smart_borders on + +######################################### +###### Gaps #### +######################################### +gaps inner 0 +gaps outer 0 + +# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are +# `pixel`, so no title bar shows: the colour says which window has focus. +# border background text indicator child_border +client.focused #de5200 #de5200 #1E2127 #de5200 #de5200 +client.urgent #900000 #900000 #ffffff #900000 #900000 + +######################################### +###### Until their modules carry them ## +######################################### +# Each line below belongs to something other than i3, named on its line. When that module is written +# it contributes the line to node-display-session, and the line goes from here in the same change. +# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals' +# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14, +# polychromatic). + +# the operator's scripts: volume, games volume, sessions, screenshot +bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up +bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down +bindsym XF86AudioMute exec --no-startup-id volume-notify mute +bindsym XF86AudioMicMute exec --no-startup-id mic-notify +bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5 +bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5 +bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh +bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +######################################### +###### Other modules' lines #### +######################################### +# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the +# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's +# under a line naming it. + +######################################### +###### Your own files #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xinitrc b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xinitrc new file mode 100644 index 00000000..2f28bc21 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xinitrc @@ -0,0 +1,86 @@ +# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at +# every push. It ends by starting the session, so no line below it in this file runs any more. A line +# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources +# just before the session starts (the module's README lists where each of today's lines went). + +# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment. +if [ -r "/home/op/.config/mesh/environment.sh" ]; then + . "/home/op/.config/mesh/environment.sh" +fi + +# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take +# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service +# twice (two notifiers, two portals) and hides one copy from the other. +if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "/run/user/$(id -u)/bus" ]; then + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" + export DBUS_SESSION_BUS_ADDRESS +fi + +# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier +# and every user service see the display and the desktop's identity. An explicit list and never +# --all: a session's environment may hold things no user service should be able to read back. +mesh_words= +for mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \ + XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \ + QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do + if printenv "$mesh_word" >/dev/null 2>&1; then + mesh_words="$mesh_words $mesh_word" + fi +done +dbus-update-activation-environment --systemd $mesh_words +unset mesh_word mesh_words + +# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged +# without the C preprocessor, so no compiler is needed for it. +xrdb -nocpp -merge "/home/op/.config/xorg/xresources" +if [ -r "$HOME/.Xresources" ]; then + xrdb -merge "$HOME/.Xresources" +fi + +# 4. The monitors: the saved layout profile that matches the monitors connected, if one does. +if command -v autorandr >/dev/null 2>&1; then + autorandr --change >/dev/null 2>&1 || true +fi + +# 5. Other modules' session lines (ADR 0208 §4), then yours. +# gnome-keyring +# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager +# starts on first use from its socket. Named here, for the session and every terminal it starts, until +# the account's environment can say a path under the runtime directory (see the module's README). +SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh" +export SSH_AUTH_SOCK +# adwaita +# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is +# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the +# module's default; adwaita_appearance switches it for a session. +gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true +gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-size 24 || true +gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true +gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true +# feh +# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session. +"$HOME/.fehbg" +# i3status-rust +# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this +# brings it back. Once per session, ending with the session's own process ($$). +"$HOME/.local/bin/i3bar-watchdog" "$$" & +# screen-lock +# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle, +# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on +# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and +# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the +# session's own process, which becomes the window manager). +xset s 1800 1800 +xset dpms 1800 1800 3600 +(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- "$HOME/.local/bin/screen-lock"; sleep 2; done) & +if [ -r "$HOME/.xinitrc.local" ]; then + . "$HOME/.xinitrc.local" +fi + +# 6. The session itself: the holder of node-display-session starts it from the last slot. +# i3 +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xresources b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xresources new file mode 100644 index 00000000..b2b7b655 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/all.xresources @@ -0,0 +1,48 @@ +! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's +! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without +! the preprocessor there is no #include or #define here, and a line starting with # is skipped, +! which is what the line naming each contributing module is. +! +! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi +! changes it for the running session. +Xft.dpi: 96 +Xft.antialias: 1 +Xft.hinting: 1 +Xft.hintstyle: hintslight +Xft.rgba: rgb +# adwaita +! adwaita: the cursor, for X programs that take it from the resources. +Xcursor.theme: Adwaita +Xcursor.size: 24 +# xterm +! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they +! outrank any `*name` resource of yours and touch no other X program. +XTerm*termName: xterm-256color +XTerm*saveLines: 10000 +XTerm*renderFont: true +XTerm*faceName: JetBrainsMono Nerd Font +XTerm*faceSize: 12 +XTerm*boldMode: false +XTerm*alwaysBoldMode: false +XTerm*allowBoldFonts: true +XTerm*background: #000000 +XTerm*foreground: #a8a8a8 +XTerm*color0: #000000 +XTerm*color8: #666666 +XTerm*color1: #9e1828 +XTerm*color9: #bc5766 +XTerm*color2: #008800 +XTerm*color10: #61a171 +XTerm*color3: #d2bb4b +XTerm*color11: #e7db52 +XTerm*color4: #414171 +XTerm*color12: #5085af +XTerm*color5: #963c59 +XTerm*color13: #a97a99 +XTerm*color6: #418179 +XTerm*color14: #6ba4a4 +XTerm*color7: #bebebe +XTerm*color15: #ffffff +XTerm*VT100.Translations: #override \n\ + Ctrl Shift ~Alt C: copy-selection(CLIPBOARD) \n\ + Ctrl Shift ~Alt V: insert-selection(CLIPBOARD) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xinitrc b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xinitrc new file mode 100644 index 00000000..30e7b6ab --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xinitrc @@ -0,0 +1,53 @@ +# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at +# every push. It ends by starting the session, so no line below it in this file runs any more. A line +# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources +# just before the session starts (the module's README lists where each of today's lines went). + +# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment. +if [ -r "/home/op/.config/mesh/environment.sh" ]; then + . "/home/op/.config/mesh/environment.sh" +fi + +# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take +# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service +# twice (two notifiers, two portals) and hides one copy from the other. +if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "/run/user/$(id -u)/bus" ]; then + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" + export DBUS_SESSION_BUS_ADDRESS +fi + +# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier +# and every user service see the display and the desktop's identity. An explicit list and never +# --all: a session's environment may hold things no user service should be able to read back. +mesh_words= +for mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \ + XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \ + QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do + if printenv "$mesh_word" >/dev/null 2>&1; then + mesh_words="$mesh_words $mesh_word" + fi +done +dbus-update-activation-environment --systemd $mesh_words +unset mesh_word mesh_words + +# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged +# without the C preprocessor, so no compiler is needed for it. +xrdb -nocpp -merge "/home/op/.config/xorg/xresources" +if [ -r "$HOME/.Xresources" ]; then + xrdb -merge "$HOME/.Xresources" +fi + +# 4. The monitors: the saved layout profile that matches the monitors connected, if one does. +if command -v autorandr >/dev/null 2>&1; then + autorandr --change >/dev/null 2>&1 || true +fi + +# 5. Other modules' session lines (ADR 0208 §4), then yours. +if [ -r "$HOME/.xinitrc.local" ]; then + . "$HOME/.xinitrc.local" +fi + +# 6. The session itself: the holder of node-display-session starts it from the last slot. +# i3 +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xresources b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xresources new file mode 100644 index 00000000..d0b1b65a --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/xorg/i3-xterm.xresources @@ -0,0 +1,44 @@ +! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's +! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without +! the preprocessor there is no #include or #define here, and a line starting with # is skipped, +! which is what the line naming each contributing module is. +! +! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi +! changes it for the running session. +Xft.dpi: 96 +Xft.antialias: 1 +Xft.hinting: 1 +Xft.hintstyle: hintslight +Xft.rgba: rgb +# xterm +! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they +! outrank any `*name` resource of yours and touch no other X program. +XTerm*termName: xterm-256color +XTerm*saveLines: 10000 +XTerm*renderFont: true +XTerm*faceName: JetBrainsMono Nerd Font +XTerm*faceSize: 12 +XTerm*boldMode: false +XTerm*alwaysBoldMode: false +XTerm*allowBoldFonts: true +XTerm*background: #000000 +XTerm*foreground: #a8a8a8 +XTerm*color0: #000000 +XTerm*color8: #666666 +XTerm*color1: #9e1828 +XTerm*color9: #bc5766 +XTerm*color2: #008800 +XTerm*color10: #61a171 +XTerm*color3: #d2bb4b +XTerm*color11: #e7db52 +XTerm*color4: #414171 +XTerm*color12: #5085af +XTerm*color5: #963c59 +XTerm*color13: #a97a99 +XTerm*color6: #418179 +XTerm*color14: #6ba4a4 +XTerm*color7: #bebebe +XTerm*color15: #ffffff +XTerm*VT100.Translations: #override \n\ + Ctrl Shift ~Alt C: copy-selection(CLIPBOARD) \n\ + Ctrl Shift ~Alt V: insert-selection(CLIPBOARD) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/xorg_golden_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/xorg_golden_test.go new file mode 100644 index 00000000..5efbdab6 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/xorg_golden_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// The graphical session's two files, xinitrc and xresources, filled by this client from the items of the collections +// shell:xinitrc: and shell:xresources: their seven contributors declare, are byte for byte what the +// controller composed from the old shell entries before the move (design 57 L3, issue 807). The module files are the +// catalogue's own; testdata/xorg holds the two files as the controller composed them, for a machine with all seven +// contributors and for one with only i3 and xterm. The not handed half is pinned in mesh-controller by the same files. + +func TestNatsTheSessionFilesFilledFromTheirContributorsItemsAreWhatTheOldFormComposed(t *testing.T) { + modules := filepath.Join("..", "..", "..") + read := func(path string) string { + b, err := os.ReadFile(filepath.Join(modules, path)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + all := []string{"adwaita", "i3status-rust", "i3", "feh", "gnome-keyring", "screen-lock", "xterm"} + for name, contributors := range map[string][]string{"all": all, "i3-xterm": {"i3", "xterm"}} { + t.Run(name, func(t *testing.T) { + facts := map[string]string{"machine:account-home": "/home/op"} + tpl, _ := templates(t, map[string]string{"xinitrc": read("xorg/files/xinitrc"), + "xresources": read("xorg/files/xresources")}) + collector := Plan{Templates: tpl, Placement: facts, Files: []PlannedFile{ + {ID: "xinitrc", Path: "/session/xinitrc", Template: "xinitrc", OwnerAtRuntime: "owned"}, + {ID: "xresources", Path: "/session/xresources", Template: "xresources", OwnerAtRuntime: "owned"}}} + for _, f := range []string{"xinitrc", "xresources"} { + for _, slot := range []string{"first", "normal", "last"} { + collector.Collections = append(collector.Collections, + PlannedCollection{Name: "shell:" + f + ":" + slot, Comment: "#"}) + } + } + var manifest struct { + Moved bool `json:"moved"` + } + if err := json.Unmarshal([]byte(read("xorg/module.json")), &manifest); err != nil || !manifest.Moved { + t.Fatalf("xorg is not moved: %v", err) + } + b := startBus(t) + e := startEngine(t, map[string]string{"/session/xinitrc": "owned", "/session/xresources": "owned"}) + b.assign(t, "xorg", collector) + for _, m := range contributors { + var c struct { + Moved bool `json:"moved"` + Shell []any `json:"shell"` + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(read(m+"/module.json")), &c); err != nil { + t.Fatal(err) + } + if !c.Moved || len(c.Shell) != 0 { + t.Fatalf("%s is not moved or still holds shell entries", m) + } + texts := map[string]string{} + var planned []PlannedItem + for _, it := range c.Items { + if it.Collection != "shell:xinitrc:first" && it.Collection != "shell:xinitrc:normal" && + it.Collection != "shell:xinitrc:last" && it.Collection != "shell:xresources:normal" { + continue + } + id := filepath.Base(it.Template) + texts[id] = read(m + "/" + it.Template) + planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id, + Attributes: it.Attributes}) + } + if len(planned) == 0 { + t.Fatalf("%s declares no item of the session files", m) + } + tp, _ := templates(t, texts) + b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts}) + } + aClient(t, b, e, t.TempDir(), time.Hour) + for _, f := range []string{"xinitrc", "xresources"} { + want, err := os.ReadFile(filepath.Join("testdata", "xorg", name+"."+f)) + if err != nil { + t.Fatal(err) + } + eventually(t, "the "+f+" is what it was", func() bool { return e.read(t, "/session/"+f) == string(want) }) + if t.Failed() { + t.Logf("got:\n%s", e.read(t, "/session/"+f)) + } + } + }) + } +} diff --git a/modules/mesh-provisioner-client/go.mod b/modules/mesh-provisioner-client/go.mod new file mode 100644 index 00000000..39b43e4a --- /dev/null +++ b/modules/mesh-provisioner-client/go.mod @@ -0,0 +1,22 @@ +module mesh-provisioner-client + +go 1.25.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.51.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-provisioner-client/go.sum b/modules/mesh-provisioner-client/go.sum new file mode 100644 index 00000000..da3d1f5d --- /dev/null +++ b/modules/mesh-provisioner-client/go.sum @@ -0,0 +1,27 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-provisioner-client/module.json b/modules/mesh-provisioner-client/module.json new file mode 100644 index 00000000..af84e540 --- /dev/null +++ b/modules/mesh-provisioner-client/module.json @@ -0,0 +1,59 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": "." + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/modules/mesh-provisioner/README.md b/modules/mesh-provisioner/README.md new file mode 100644 index 00000000..88c246b3 --- /dev/null +++ b/modules/mesh-provisioner/README.md @@ -0,0 +1,33 @@ +# mesh-provisioner + +Holds the `mesh-provisioner` seat once for the mesh: the provisioner's server (novox/hq ADR 0349 clause 3, design 57 +P4). Go, one binary (`cmd/mesh-provisioner`), served by a runtime of its own on its own bus account (`runs-as`), so +that its account alone writes the registry. The provisioner client on each node (`node-provisioner`, design 57 P5) +is the module `mesh-provisioner-client`. + +## What it keeps + +- `mesh-provisioner_registry`: which domain answers which kind of placeholder, keyed by the kind (`config`, + `secret`, `bound` — the provider's own bucket, `_bound`, keyed + `..[.]` (mesh-sdk `go/provider`) — `consumers` — a provider's consumers' + credentials, asked of the vault by the provider machine's client (novox/hq ADR 0373) — `collected` — and `shell`, `environment` and `contribution`, read as collections of the same + name (design 57 L1) — and the placement namespaces `dir`, `access`, `port`, `seat`, `machine`). Each + answer names the owning module and where a client finds the values: a domain's bucket and its key shape + (placement's is the controller's assignments mirror, whose value carries the provisioner's plan of a module handed + to it), or a seat's verb asked on request and reply (a secret is never in a bucket). Compiled in + `registry.go`, written at start and every hour, only where it differs; a kind no domain answers is deleted. +- `mesh-provisioner_needs`: what each module on each node needs, `...`. +- `mesh-provisioner_applied`: what is applied where, `.`. +- `mesh-provisioner_collect`: the items of every collection, `..`, the collection + with a character a key's token cannot hold written `=` (`shell=3Azsh=3Anormal` is `shell:zsh:normal`), and + read back by its name. + +The last three are written by each node's provisioner client for its own node's keys alone: the controller grants +the client `.>` of each and nothing wider (mesh-controller `internal/broker/provisioner.go`), and a provider +reads the needs without writing them. This module writes only the registry. + +## The verb + +`mesh-provisioner.provisioning` with `what` `registry` (the default), `needs`, `applied` or `collect`, narrowed by +`node` and `module` (for `collect`, the contributor) and, for `collect`, `collection`. Reads only. A key out of its +bucket's shape is said in the answer, never dropped quietly. diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go new file mode 100644 index 00000000..3955516d --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go @@ -0,0 +1,95 @@ +// mesh-provisioner: the provisioner's server, holder of the mesh-provisioner seat once for the mesh (novox/hq ADR +// 0349 clause 3, design 57 P4). A Go bundle served by a runtime of its own, on its own bus account (`runs-as`, ADR +// 0259 §8), keeping four buckets: mesh-provisioner_registry (which domain answers which kind of placeholder, its +// alone), and mesh-provisioner_needs, _applied and _collect, which each machine's provisioner client writes for its +// own machine (the controller grants the client those keys alone, issue 670). stdout is the MCP channel; what this +// module says, it says on stderr. +package main + +import ( + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds, once for the mesh, and Verb its one verb. +const ( + Seat = "mesh-provisioner" + Verb = "provisioning" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// runtimeStore is the bus, through the runtime that launched this bundle. +type runtimeStore struct{} + +func (runtimeStore) Get(state, key string) (*Entry, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, err + } + return &Entry{Key: e.Key, Value: e.Value, Revision: e.Revision}, nil +} + +func (runtimeStore) Put(state, key string, value any) error { + _, err := stdio.State(state).Put(key, value) + return err +} + +func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) } + +func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +func main() { + go publishing(runtimeStore{}) + if err := stdio.Serve("", Tools(runtimeStore{})); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// publishing writes the registry once the runtime answers, and again every hour, so a registry emptied by hand or +// a bucket made again is written back without a restart. The runtime answers only once Serve runs, so the first +// pass is tried again until it lands. +func publishing(s Store) { + for wait := 2 * time.Second; ; { + time.Sleep(wait) + written, deleted, err := Publish(s) + switch { + case err != nil: + logf("[mesh-provisioner] the registry is not written yet (%v); trying again in %s", err, wait) + wait = min(wait*2, time.Minute) + continue + case len(written) > 0 || len(deleted) > 0: + logf("[mesh-provisioner] the registry: wrote %v, deleted %v", written, deleted) + } + wait = time.Hour + } +} + +// Tools is the seat's verb as the runtime serves it. +func Tools(s Store) []stdio.Tool { + return []stdio.Tool{{ + Name: Seat + "." + Verb, + Description: "Read what the provisioner keeps (novox/hq ADR 0349 clause 3): the registry (which domain " + + "answers which kind of placeholder, the default), the needs each module on each node recorded, what is " + + "applied where, or the items of every collection, narrowed by node and module, and collect by " + + "collection. Reads only: each node's provisioner client writes its own node's needs, applied and items.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "what": map[string]any{"type": "string", "enum": []string{Registry, Needs, Applied, Collect}, + "description": "registry when not given"}, + "node": map[string]any{"type": "string", "description": "a node"}, + "module": map[string]any{"type": "string", "description": "a module: for collect, a contributor"}, + "collection": map[string]any{"type": "string", "description": "for collect: a collection"}, + }}, + Run: func(args map[string]any) (any, error) { + q, err := queryOf(args) + if err != nil { + return nil, err + } + return Do(s, q) + }, + }} +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go new file mode 100644 index 00000000..4661b226 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "sort" + "strings" + "sync" + "testing" +) + +// memStore is the bus as mesh-provisioner reaches it, in memory: the states it keeps. +type memStore struct { + mu sync.Mutex + states map[string]map[string][]byte + puts int +} + +func newStore() *memStore { return &memStore{states: map[string]map[string][]byte{}} } + +func (m *memStore) Get(state, key string) (*Entry, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.states[state][key] + if !ok { + return nil, nil + } + return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil +} + +func (m *memStore) Put(state, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + if m.states[state] == nil { + m.states[state] = map[string][]byte{} + } + m.states[state][key] = raw + m.puts++ + return nil +} + +func (m *memStore) Delete(state, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.states[state], key) + return nil +} + +func (m *memStore) Keys(state string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + var keys []string + for k := range m.states[state] { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +// write is a client's write, as the bus lets it through for its own machine's keys. +func (m *memStore) write(t *testing.T, state, key, raw string) { + t.Helper() + if err := m.Put(state, key, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } +} + +// The registry answers every kind a moved module's template may name (ADR 0349 clause 2): config, secret, bound, +// collected and the placement namespaces, each with its domain and where a client finds it. +func TestTheRegistryAnswersEveryKindATemplateMayName(t *testing.T) { + var kinds []string + for _, a := range TheRegistry { + kinds = append(kinds, a.Kind) + if a.Domain == "" || a.Why == "" || !strings.HasPrefix(a.Decision, "novox/hq ADR ") { + t.Errorf("%s: an answer names its domain, why and its decision: %+v", a.Kind, a) + } + switch a.From { + case FromBucket: + if a.Bucket == "" || !strings.HasPrefix(a.Key, ".") { + t.Errorf("%s: a value in a bucket names the bucket and a key with the node first: %+v", a.Kind, a) + } + if a.Ask != "" { + t.Errorf("%s: from a bucket, asked of nobody: %+v", a.Kind, a) + } + case FromAsk: + if a.Ask == "" || a.Bucket != "" { + t.Errorf("%s: a value asked for names its verb and no bucket: %+v", a.Kind, a) + } + default: + t.Errorf("%s: from %q", a.Kind, a.From) + } + } + want := []string{"config", "secret", "bound", "consumers", "collected", "shell", "environment", "contribution", + "dir", "access", "port", "seat", "machine", "backup"} + if !slices.Equal(kinds, want) { + t.Errorf("the registry's kinds are %v, want %v", kinds, want) + } + // The buckets a client is granted to read its node's keys of (mesh-controller internal/broker/provisioner.go): + // the config domain's resolved values, this module's own collections, and placement in each module's assignment, + // where the controller carries the provisioner's plan (issue 671): a client never reads the node's declaration. + for kind, bucket := range map[string]string{"config": "mesh-config_resolved", "collected": "mesh-provisioner_" + Collect, + "shell": "mesh-provisioner_" + Collect, "environment": "mesh-provisioner_" + Collect, + "contribution": "mesh-provisioner_" + Collect, + "dir": "mesh-controller_assignments", "access": "mesh-controller_assignments", + "port": "mesh-controller_assignments", "seat": "mesh-controller_assignments", + "machine": "mesh-controller_assignments", "backup": "mesh-controller_assignments"} { + i := slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == kind }) + if TheRegistry[i].Bucket != bucket { + t.Errorf("%s is found in %s, want %s", kind, TheRegistry[i].Bucket, bucket) + } + } + // A secret is never in a bucket (ADR 0201 §13): asked of the vault. + i := slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "secret" }) + if a := TheRegistry[i]; a.From != FromAsk || a.Ask != "mesh-vault.get" { + t.Errorf("a secret is answered by %+v, want asked of mesh-vault.get", a) + } + // A consumer's values are in its provider's own bucket, one entry per consumer, keyed as the providers' helper + // writes it (mesh-sdk go/provider, issue 678): the consumer's machine first. + i = slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "bound" }) + if a := TheRegistry[i]; a.From != FromBucket || a.Bucket != "_bound" || + a.Key != "..[.]" { + t.Errorf("a bound value is answered by %+v, want the provider's _bound, "+ + "..[.]", a) + } + // A provider's consumers' credentials are the vault's, asked by the provider machine's client (ADR 0373). + i = slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "consumers" }) + if a := TheRegistry[i]; a.Domain != "mesh-vault" || a.From != FromAsk || a.Ask != "mesh-vault.get" || + a.Decision != "novox/hq ADR 0373" { + t.Errorf("a provider's consumers are answered by %+v, want the vault, asked of mesh-vault.get (ADR 0373)", a) + } +} + +// The registry is written whole at start, a pass that finds it equal writes nothing, a changed answer is written +// back, and a kind no domain answers any more is deleted. +func TestTheRegistryIsWrittenBackAndAQuietPassWritesNothing(t *testing.T) { + s := newStore() + written, deleted, err := Publish(s) + if err != nil { + t.Fatal(err) + } + if len(written) != len(TheRegistry) || len(deleted) != 0 { + t.Fatalf("the first pass wrote %v and deleted %v", written, deleted) + } + for _, a := range TheRegistry { + e, _ := s.Get(Registry, a.Kind) + var got Answer + if e == nil || json.Unmarshal(e.Value, &got) != nil || got != a { + t.Errorf("the registry's %s holds %v, want %+v", a.Kind, e, a) + } + } + puts := s.puts + if written, deleted, err := Publish(s); err != nil || len(written)+len(deleted) != 0 || s.puts != puts { + t.Fatalf("a quiet pass wrote %v, deleted %v (%v)", written, deleted, err) + } + s.write(t, Registry, "config", `{"kind":"config","domain":"mesh-controller"}`) + s.write(t, Registry, "setting", `{"kind":"setting"}`) + written, deleted, err = Publish(s) + if err != nil || !slices.Equal(written, []string{"config"}) || !slices.Equal(deleted, []string{"setting"}) { + t.Fatalf("a changed and an unknown kind: wrote %v, deleted %v (%v)", written, deleted, err) + } +} + +func clientWrites(t *testing.T) *memStore { + s := newStore() + if _, _, err := Publish(s); err != nil { + t.Fatal(err) + } + s.write(t, Needs, "g14.dunst.config.font-size", `{"at":"2026-10-14T20:00:00Z"}`) + s.write(t, Needs, "g14.dunst.secret.token", `{}`) + s.write(t, Needs, "shanks.dunst.config.font-size", `{}`) + s.write(t, Needs, "shanks.gitea.bound.database.user", `{}`) + s.write(t, Applied, "g14.dunst", `{"build":"b1","outcome":"applied"}`) + s.write(t, Applied, "shanks.dunst", `{"build":"b1","outcome":"failed","reason":"waits for config font-size"}`) + s.write(t, Collect, "g14.zsh-snippets.git", `{}`) + s.write(t, Collect, "g14.bar-blocks.disk-load", `{}`) + s.write(t, Collect, "g14.shell=3Azsh=3Anormal.mesh-cli", `{}`) + return s +} + +func keysOf(r Read) []string { + var out []string + for _, row := range r.Rows { + out = append(out, row.Key) + } + return out +} + +// Each bucket is read by its shape, its parts named, and narrowed by node, module and collection. +func TestEachBucketIsReadByItsShapeAndNarrowed(t *testing.T) { + s := clientWrites(t) + for _, c := range []struct { + q Query + want []string + }{ + {Query{}, []string{"access", "backup", "bound", "collected", "config", "consumers", "contribution", "dir", "environment", + "machine", "port", "seat", "secret", "shell"}}, + {Query{What: Needs}, []string{"g14.dunst.config.font-size", "g14.dunst.secret.token", + "shanks.dunst.config.font-size", "shanks.gitea.bound.database.user"}}, + {Query{What: Needs, Node: "g14"}, []string{"g14.dunst.config.font-size", "g14.dunst.secret.token"}}, + {Query{What: Needs, Module: "dunst", Node: "shanks"}, []string{"shanks.dunst.config.font-size"}}, + {Query{What: Applied, Module: "dunst"}, []string{"g14.dunst", "shanks.dunst"}}, + {Query{What: Applied, Node: "ace"}, nil}, + {Query{What: Collect, Collection: "zsh-snippets"}, []string{"g14.zsh-snippets.git"}}, + {Query{What: Collect, Module: "disk-load"}, []string{"g14.bar-blocks.disk-load"}}, + // An old namespace's collection, by its name, not its key's token (design 57 L1). + {Query{What: Collect, Collection: "shell:zsh:normal"}, []string{"g14.shell=3Azsh=3Anormal.mesh-cli"}}, + } { + r, err := Do(s, c.q) + if err != nil { + t.Fatalf("%+v: %v", c.q, err) + } + if got := keysOf(r); !slices.Equal(got, c.want) || r.Count != len(c.want) { + t.Errorf("%+v: read %v (%d), want %v", c.q, got, r.Count, c.want) + } + } + r, _ := Do(s, Query{What: Needs, Node: "shanks", Module: "gitea"}) + if row := r.Rows[0]; row.Node != "shanks" || row.Module != "gitea" || row.Kind != "bound" || row.Name != "database.user" { + t.Errorf("a need's parts: %+v", row) + } + r, _ = Do(s, Query{What: Collect, Collection: "bar-blocks"}) + if row := r.Rows[0]; row.Node != "g14" || row.Collection != "bar-blocks" || row.Module != "disk-load" { + t.Errorf("an item's parts: %+v", row) + } + r, _ = Do(s, Query{What: Applied, Node: "shanks"}) + if string(r.Rows[0].Value) != `{"build":"b1","outcome":"failed","reason":"waits for config font-size"}` { + t.Errorf("what is applied is read as the client wrote it: %s", r.Rows[0].Value) + } +} + +// A key out of its bucket's shape is said, never dropped quietly. +func TestAKeyOutOfItsBucketsShapeIsSaid(t *testing.T) { + s := clientWrites(t) + s.write(t, Applied, "g14.dunst.extra", `{}`) + r, err := Do(s, Query{What: Applied}) + if err != nil { + t.Fatal(err) + } + if r.Count != 2 || !strings.Contains(r.Said, "g14.dunst.extra") || !strings.Contains(r.Said, ".") { + t.Errorf("an unshaped key: %d rows, said %q", r.Count, r.Said) + } +} + +// The verb's arguments are read strictly, and a narrowing that means nothing is refused rather than ignored. +func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) { + s := clientWrites(t) + run := Tools(s)[0].Run + if Tools(s)[0].Name != "mesh-provisioner.provisioning" { + t.Fatalf("the verb is served as %s", Tools(s)[0].Name) + } + for _, args := range []map[string]any{ + {"what": "needs", "caller": "g14.dunst"}, + {"node": 3}, + {"what": "secrets"}, + {"node": "g14"}, + {"what": "needs", "collection": "zsh-snippets"}, + } { + if _, err := run(args); err == nil { + t.Errorf("%v was taken", args) + } + } + got, err := run(map[string]any{"what": "applied", "node": " g14 "}) + if err != nil { + t.Fatal(err) + } + if r := got.(Read); r.Count != 1 || r.Said != "1 in applied for node g14" { + t.Errorf("a read: %+v", r) + } +} + +// The manifest declares the seat, its verb, the four buckets this code keeps, and an account of its own, so that +// its account alone (with each client for its machine's keys) writes them. +func TestTheManifestDeclaresWhatTheCodeKeeps(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + RunsAs string `json:"runs-as"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` + State []string `json:"state"` + OwnSecrets map[string]string `json:"own-secrets"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Module != "mesh-provisioner" || m.RunsAs != "mesh-provisioner" || m.OwnSecrets["broker"] == "" { + t.Errorf("an account of its own: module %q, runs-as %q, own secrets %v", m.Module, m.RunsAs, m.OwnSecrets) + } + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" || + !slices.Equal(m.Claims[0].Serves, []string{Verb}) { + t.Errorf("the claim: %+v", m.Claims) + } + if want := []string{Registry, Needs, Applied, Collect}; !slices.Equal(m.State, want) { + t.Errorf("the state is %v, want %v", m.State, want) + } +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioning.go b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioning.go new file mode 100644 index 00000000..c5b0deba --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioning.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strconv" + "strings" +) + +// The verb `provisioning`: a read of what this module keeps, narrowed by node and module. It writes nothing: +// the registry is this module's, and the needs, the applied and the items are each machine's client's, for its +// own machine (ADR 0349 clause 3). + +// Entry is one key's current value. +type Entry struct { + Key string + Value json.RawMessage + Revision uint64 +} + +// Store is the bus as this module reaches it: the state it keeps. +type Store interface { + Get(state, key string) (*Entry, error) + Put(state, key string, value any) error + Delete(state, key string) error + Keys(state string) ([]string, error) +} + +// Query is one call of the verb. +type Query struct { + What string + Node string + Module string + Collection string +} + +// Row is one key of a bucket, its parts named, and its value as written. +type Row struct { + Key string `json:"key"` + Node string `json:"node,omitempty"` + Module string `json:"module,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Collection string `json:"collection,omitempty"` + Value json.RawMessage `json:"value"` +} + +// Read is a read's answer: the bucket read, its rows, how many, and what narrowed them. +type Read struct { + What string `json:"what"` + Rows []Row `json:"rows"` + Count int `json:"count"` + Said string `json:"said"` +} + +// shapes are the keys of each bucket, the node first, as the clients write them. +var shapes = map[string]string{ + Needs: "...", + Applied: ".", + Collect: "..", +} + +// rowOf names a key's parts by its bucket's shape, or says the key is not in that shape. +func rowOf(what, key string) (Row, bool) { + r := Row{Key: key} + switch what { + case Registry: + r.Kind = key + return r, key != "" + case Needs: + parts := strings.SplitN(key, ".", 4) + if len(parts) != 4 || slices.Contains(parts, "") { + return r, false + } + r.Node, r.Module, r.Kind, r.Name = parts[0], parts[1], parts[2], parts[3] + case Applied: + parts := strings.Split(key, ".") + if len(parts) != 2 || slices.Contains(parts, "") { + return r, false + } + r.Node, r.Module = parts[0], parts[1] + case Collect: + parts := strings.SplitN(key, ".", 3) + if len(parts) != 3 || slices.Contains(parts, "") { + return r, false + } + // The contributor is the module whose item it is; the collection as its key's token writes it, a character a + // token cannot hold written `=` (`shell=3Azsh=3Anormal` is `shell:zsh:normal`). + collection, ok := fromKeyToken(parts[1]) + if !ok { + return r, false + } + r.Node, r.Collection, r.Module = parts[0], collection, parts[2] + default: + return r, false + } + return r, true +} + +// Do answers a read. +func Do(s Store, q Query) (Read, error) { + what := q.What + if what == "" { + what = Registry + } + if _, ok := shapes[what]; !ok && what != Registry { + return Read{}, fmt.Errorf("provisioning reads registry, needs, applied or collect, not %q", what) + } + if what == Registry && (q.Node != "" || q.Module != "" || q.Collection != "") { + return Read{}, fmt.Errorf("the registry is the same for every node and module; it is not narrowed") + } + if q.Collection != "" && what != Collect { + return Read{}, fmt.Errorf("collection narrows collect alone, not %s", what) + } + keys, err := s.Keys(what) + if err != nil { + return Read{}, fmt.Errorf("listing %s: %w", what, err) + } + out := Read{What: what, Rows: []Row{}} + var unshaped []string + for _, k := range keys { + r, ok := rowOf(what, k) + if !ok { + unshaped = append(unshaped, k) + continue + } + if (q.Node != "" && r.Node != q.Node) || (q.Module != "" && r.Module != q.Module) || + (q.Collection != "" && r.Collection != q.Collection) { + continue + } + e, err := s.Get(what, k) + if err != nil { + return Read{}, fmt.Errorf("reading %s's %s: %w", what, k, err) + } + if e == nil { + continue // deleted between the listing and the read + } + r.Value = e.Value + out.Rows = append(out.Rows, r) + } + sort.Slice(out.Rows, func(i, j int) bool { return out.Rows[i].Key < out.Rows[j].Key }) + out.Count = len(out.Rows) + out.Said = said(what, q, out.Count) + if len(unshaped) > 0 { + // Said, never dropped quietly: a key not in its bucket's shape is a client writing what it should not. + out.Said += fmt.Sprintf("; %d key(s) are not in the shape %s and are not listed: %s", len(unshaped), + shapes[what], strings.Join(unshaped, ", ")) + } + return out, nil +} + +func said(what string, q Query, n int) string { + var narrowed []string + for _, p := range [][2]string{{"node", q.Node}, {"module", q.Module}, {"collection", q.Collection}} { + if p[1] != "" { + narrowed = append(narrowed, p[0]+" "+p[1]) + } + } + s := fmt.Sprintf("%d in %s", n, what) + if len(narrowed) > 0 { + s += " for " + strings.Join(narrowed, ", ") + } + return s +} + +// queryOf reads a call's arguments strictly: an argument it does not know is refused, not ignored. +func queryOf(args map[string]any) (Query, error) { + var q Query + fields := map[string]*string{"what": &q.What, "node": &q.Node, "module": &q.Module, "collection": &q.Collection} + var unknown []string + for k, v := range args { + into, ok := fields[k] + if !ok { + unknown = append(unknown, k) + continue + } + if v == nil { + continue + } + s, ok := v.(string) + if !ok { + return Query{}, fmt.Errorf("%s is a string", k) + } + *into = strings.TrimSpace(s) + } + if len(unknown) > 0 { + sort.Strings(unknown) + return Query{}, fmt.Errorf("provisioning takes no %s; it takes what, node, module and collection", + strings.Join(unknown, ", ")) + } + return q, nil +} + +// fromKeyToken reads a key's token as the client writes it (mesh-provisioner-client collect.go): `=` is the +// character of that code. +func fromKeyToken(s string) (string, bool) { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '=' { + b.WriteByte(s[i]) + continue + } + if i+2 >= len(s) { + return "", false + } + v, err := strconv.ParseUint(s[i+1:i+3], 16, 8) + if err != nil { + return "", false + } + b.WriteByte(byte(v)) + i += 2 + } + return b.String(), true +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/registry.go b/modules/mesh-provisioner/cmd/mesh-provisioner/registry.go new file mode 100644 index 00000000..7d7d6f3e --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/registry.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" +) + +// The registry: which domain answers which kind of placeholder (novox/hq ADR 0349 clauses 1 and 3). A kind is the +// namespace of a placeholder, `${:…}`; its answer says which module owns the values and where a node's +// provisioner client finds them for its node. Compiled here and written to the bucket mesh-provisioner_registry, +// which this module alone writes and every client reads: a domain that comes to answer a kind is a change to this +// table, through the design that moves it (design 57). + +// The buckets this module keeps (its manifest's `state`), by local name. +const ( + Registry = "registry" + Needs = "needs" + Applied = "applied" + Collect = "collect" +) + +// Where a client finds a kind's values. +const ( + // FromBucket is a domain's bucket, watched for the node's keys: a watch delivers the current values first. + FromBucket = "bucket" + // FromAsk is a seat's verb asked on request and reply: a value never kept in a bucket (ADR 0201 §13). + FromAsk = "ask" +) + +// Answer is one kind's entry in the registry. +type Answer struct { + // Kind is the placeholder's namespace. + Kind string `json:"kind"` + // Domain is the module that owns the kind's values; for bound values, the module providing the provision + // the placeholder names, which is not one module but whichever the mesh resolved. + Domain string `json:"domain"` + // From is where a client finds the values: FromBucket or FromAsk. + From string `json:"from"` + // Bucket and Key, for a value in a bucket: the bucket, and the shape of a key in it, the node first. + Bucket string `json:"bucket,omitempty"` + Key string `json:"key,omitempty"` + // Ask, for a value asked for: the seat's verb, `.`. + Ask string `json:"ask,omitempty"` + // Why says the answer in a sentence, and Decision the record that decided it. + Why string `json:"why"` + Decision string `json:"decision"` +} + +// placement is the controller's: where a module runs, its directories, accesses, ports, seats and the machine's +// facts (ADR 0349 clause 6). On the bus, in the provisioner's plan of each module the controller handed to it, which +// its assignment carries (mesh-controller internal/inventory Provisioning, issue 671), or for a module only pulled in +// by another, its value of its own under the same key (issue 765): a client never reads the node's declaration, +// which the node-engine keeps for root alone. +func placement(kind, what string) Answer { + return Answer{Kind: kind, Domain: "mesh-controller", From: FromBucket, Bucket: "mesh-controller_assignments", + Key: ".", + Why: what + ": placement, the controller's, in the provisioner's plan its assignment carries (provisioning." + + "placement)", Decision: "novox/hq ADR 0349 clause 6"} +} + +// oldCollection is a namespace read as a collection of its own name (novox/hq ADR 0349 clause 13, design 57 L1). +func oldCollection(kind, what string) Answer { + return Answer{Kind: kind, Domain: "mesh-provisioner", From: FromBucket, Bucket: "mesh-provisioner_collect", + Key: "..", + Why: what + ": read as the collection `" + kind + ":`, its items recorded by the client of the node " + + "its contributors are placed on", Decision: "novox/hq ADR 0349 clause 13"} +} + +// TheRegistry is every kind a moved module's template may name, and its answer, in the order of ADR 0349 clause 2. +var TheRegistry = []Answer{ + {Kind: "config", Domain: "mesh-config", From: FromBucket, Bucket: "mesh-config_resolved", Key: "..", + Why: "a config value, resolved for each module on each node by mesh-config", Decision: "novox/hq ADR 0348"}, + {Kind: "secret", Domain: "mesh-vault", From: FromAsk, Ask: "mesh-vault.get", + Why: "a shared secret, handed by the vault sealed to the client's key, on request and reply only", + Decision: "novox/hq ADR 0349 clause 11"}, + // The provider's bucket and its keys, as the providers' helper writes them (mesh-sdk go/provider, issue 678): the + // bucket of the module the consumer's plan names as its provider, and the consumer's machine first. + {Kind: "bound", Domain: "provider", From: FromBucket, Bucket: "_bound", + Key: "..[.]", + Why: "a consumer's values, written by the module providing the provision to its own bucket, one entry per " + + "consumer, the bucket of the provider module the consumer's plan names", Decision: "novox/hq ADR 0349 clause 12"}, + // A provider's consumers' credentials, each consumer's pair credential, which the provider machine's client asks + // the vault for and fills into a file the provider alone reads (issue 679). + {Kind: "consumers", Domain: "mesh-vault", From: FromAsk, Ask: "mesh-vault.get", + Why: "each bound consumer's credential, asked of the vault by the provider machine's client and filled into a " + + "file only the provider reads, one entry per consumer", Decision: "novox/hq ADR 0373"}, + {Kind: "collected", Domain: "mesh-provisioner", From: FromBucket, Bucket: "mesh-provisioner_collect", + Key: "..", + Why: "the items of a collection, recorded by the client of the node its contributors are placed on", + Decision: "novox/hq ADR 0349 clause 13"}, + // The three namespaces the controller fills for a module not handed to the provisioner, read in a handed + // collector's template as the collection of the same name (design 57 L1): `${shell:zsh:normal}` is the + // collection `shell:zsh:normal`. + oldCollection("shell", "a shell's code in one slot, or a session file's or a power moment's"), + oldCollection("environment", "the account's environment in one format"), + oldCollection("contribution", "what a seat's holder receives of one kind"), + placement("dir", "a module's directory"), + placement("access", "a directory a module reaches of another's"), + placement("port", "a module's port"), + placement("seat", "where a seat's holder is reached"), + placement("machine", "a fact of the machine"), + placement("backup", "what the machine's modules back up: `lines` and `data`, as the controller derives them from each module's declared data"), +} + +// Publish brings the registry bucket to TheRegistry: a kind whose answer differs is written, a kind no longer in +// the table is deleted, and a pass that finds them equal writes nothing. It answers the kinds it wrote and +// deleted. +func Publish(s Store) (written, deleted []string, err error) { + known := map[string]bool{} + for _, a := range TheRegistry { + known[a.Kind] = true + want, err := json.Marshal(a) + if err != nil { + return written, deleted, err + } + have, err := s.Get(Registry, a.Kind) + if err != nil { + return written, deleted, fmt.Errorf("reading the registry's %s: %w", a.Kind, err) + } + if have != nil && sameJSON(have.Value, want) { + continue + } + if err := s.Put(Registry, a.Kind, a); err != nil { + return written, deleted, fmt.Errorf("writing the registry's %s: %w", a.Kind, err) + } + written = append(written, a.Kind) + } + keys, err := s.Keys(Registry) + if err != nil { + return written, deleted, fmt.Errorf("listing the registry: %w", err) + } + for _, k := range keys { + if known[k] { + continue + } + if err := s.Delete(Registry, k); err != nil { + return written, deleted, fmt.Errorf("deleting the registry's %s, a kind no domain answers: %w", k, err) + } + deleted = append(deleted, k) + } + slices.Sort(deleted) + return written, deleted, nil +} + +// sameJSON says two JSON texts hold the same value, whatever their spacing or the order of their keys. +func sameJSON(a, b []byte) bool { + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return bytes.Equal(a, b) + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return bytes.Equal(ja, jb) +} diff --git a/modules/mesh-provisioner/go.mod b/modules/mesh-provisioner/go.mod new file mode 100644 index 00000000..e89c5f04 --- /dev/null +++ b/modules/mesh-provisioner/go.mod @@ -0,0 +1,5 @@ +module mesh-provisioner + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 diff --git a/modules/mesh-provisioner/go.sum b/modules/mesh-provisioner/go.sum new file mode 100644 index 00000000..7e6c029b --- /dev/null +++ b/modules/mesh-provisioner/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-provisioner/module.json b/modules/mesh-provisioner/module.json new file mode 100644 index 00000000..11133aef --- /dev/null +++ b/modules/mesh-provisioner/module.json @@ -0,0 +1,55 @@ +{ + "module": "mesh-provisioner", + "version": "1", + "runs-as": "mesh-provisioner", + "claims": [ + { + "name": "mesh-provisioner", + "scope": "mesh", + "serves": [ + "provisioning" + ] + } + ], + "state": [ + "registry", + "needs", + "applied", + "collect" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner", + "binary": "mesh-provisioner", + "loads": [ + "mesh-provisioner" + ] + } + ] + } +} diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index b34b94c0..d51bdd7a 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -76,17 +76,19 @@ Two boxes on the mesh today, both over X25519: What the vault uses: - **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its - public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is - X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private - half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is - refused: a raw private key looks the same, and the vault takes no private key. + public key as SPKI PEM. A node's provisioner client (V4) asks with a key of its own, made when it starts + and kept in its memory alone: it runs under an account of its own and cannot read the node-engine's sealing + key, which is root's. A node's sealing key would open an answer too, being X25519 (wrapped as SPKI and PKCS #8, + `TestANodesSealingKeyOpensTheAnswer`), for the node-engine's own asks. A raw 32-byte key is refused: a raw + private key looks the same, and the vault takes no private key. - **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the operator's recovery opens them with the key and the command it already has. - **The at-rest copy is the ADR 0206 box**, to the vault's own key. The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the -vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine. +vault (ADR 0349 §16), the node-engine is to open the ADR 0206 box with the same sealing key; that is not built +yet (issue 647 leaves it to its own issue). ## The seat's verb @@ -102,15 +104,107 @@ bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, eac journal and counted in `vault_status`. `secret_handed` reads it. `vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in -the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health -check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so -nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person -through the journal and this tool only. +the export the vault refused, and how often the record was rolled. -**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account -of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither -exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes -behind the `Askers` interface in `askers.go` (design 57's V4 and P3). +**A failing re-seal is a condition** (ADR 0366, issue 738). The vault re-seals its operator copies to the +operator key once a minute. `vault_health` answers unhealthy once that has failed on three passes in a row, +saying what failed and why, and healthy again after one pass succeeds. The manifest declares it as the +health of the vault's own runtime (`runtime-health`), which the controller puts on `mesh-vault.own-runtime`; +the node-engine asks it every minute and the controller raises `module.mesh-vault..unhealthy` from +it, cleared on the first healthy answer. An operator key in the export the vault refuses fails the pass too. +How often the record was rolled still reaches a person through the journal and `vault_status` only. + +**Who may ask: a node's provisioner client, for what its plan names** (ADR 0349 §11, design 57 V4, issue 647; +`askers.go`). The caller is `provisioner.`, the client's own account (never the tool runner's, which an agent +on that machine shares), and the secret is `own///` for that same machine, where the +controller's assignment `.` (the assignments mirror, which the manifest `reads`) carries the +provisioner's plan and the plan's `secrets` name it; or a value a provider's software issued for one holder, +`provision////`, where that plan's `provisions` name the provision from that +provider with a credential's `name` (issue 757; an entry naming only the provider serving a provision, issue 760, +hands nothing). The vault cannot read a node's declaration, so the plan the +controller carries beside it is what the declaration names, as the vault reads it. A provision's one credential, +`provision//`, is handed to the machines the vault made it for (below) while the holder there +is handed to the provisioner: the provider's module, or a consumer whose plan still names it from that provider. A +module not handed to the provisioner, another machine's secret or holder value, and every other caller are refused, +and each refusal is recorded with why. A module's own account (issue 569) is not answered yet. + +**The controller, for the core's own credentials, sealed only to their machine** (ADR 0349 §16, issue 763; +`core.go`). The bus and mesh-provisioner need their files before any client runs, so their credentials travel in +the declaration sealed by the vault to their machine, which the controller carries and cannot open. The caller +`controller` is answered `own///` only for a module on the core's fixed list (`nats`, +`mesh-provisioner`, `mesh-provisioner-client`, the controller's list too), and only when the key it names is that +machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`). +A machine whose engine stated none, any other key, any other module and any other name are refused and recorded. + +## Taking values in: `accept`, `secret-ask`, `issued`, `rotate` + +Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the +controller's place). `intake.go`. + +**From the controller alone; the vault asks the desk and the provider itself.** The controller alone asks these +verbs, as the runtime proves its caller. Each value comes in sealed to an X25519 key the vault made for that one +call (raw, base64 — the NaCl anonymous box the desk's prompt and a provider's tool already seal to), and opens with +the private half, which lives in this process only and is forgotten once used or after two minutes. A value sealed +to any other key is refused, and a call is answered once. + +**The vault's own account** (issue 723). The vault runs as `mesh-vault` in a runtime of its own (`runs-as`, ADR +0259 §8), on a bus account of its own (`.mesh-vault`, from its own secret `broker`). The bus lets that +account call the desks' prompt (`node-launcher.secret` on any machine) and each issuing provider's tool in its own +name, and nothing else outside its seat (mesh-controller's `internal/broker` grants, proven on a real server). So +the prompt and the tool know the vault asks, and the controller carries neither the key nor the sealed value. The +controller still asks them itself for a module filled the old way, until R1 (issue 682) removes that path; they +answer both until then. Everything the vault keeps is its account's: its directories, the export it keeps, the +grant files the controller still writes for it. + +- `accept` — two steps: node, module, name (a module's own secret) or provider, provision (a provision's one + credential), and `by`; then `call` and `sealed`. The controller's terminal seals the value it read to the call's + key before it leaves the terminal. Kept with origin `accepted`; an accepted provision credential is never remade + (`rotate` refuses it); a value a provider's software issued is not taken by `accept`. +- `secret-ask` — one step: node, module, name, desk and `asked_by` (who asked the controller, as the bus named its + caller). The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same + secret is open (two minutes at most) or when three were made in the last hour. The vault then asks the desk's + prompt itself, naming who asked as the record has it, with `timeout_seconds` 20. The answer is how it ended + (`given`, dismissed, timed-out, empty, refused, failed) and who asked; how each ask ended is recorded. + `secret_asks` reads the record. The hand-act log stays the controller's: it records the act once the vault has + taken the value. +- `issued` — one step: provision, provider (machine), module and tool. The vault asks that tool on the provider's + machine itself; the value is kept as the provision's one credential with origin `issued`. The same value again + writes nothing, says nothing and keeps the generation (ADR 0305 §5). +- `issued` for each holder apart (issue 749; ADR 0349 clause 11, ADR 0365 clause 4: the forge's read token per agent + account). The controller names the provision's holders from that provider (`holders`, each a node and a consuming + module), and the vault asks the tool with them. The tool answers either one value (`sealed`, kept as above) or one + per holder (`holders`: node, module and `sealed` each, all sealed to the call's key). Each is kept as + `provision////`, origin `issued`, and the same value again for + a holder writes nothing for it. The answer is taken whole or not at all: a holder not asked about, one named twice, a + value not sealed to the call or an empty one keeps nothing. A holder asked about and not answered is said + (`not_answered`) and keeps nothing. **`get` hands a holder's value to that holder alone**, its machine's provisioner + client (`provisioner.`) or the module under an account of its own (`.`), whatever the rule for + who may ask allows; every other caller is refused and the refusal recorded. + `TestOnARealBusTwoHoldersOnTwoMachinesEachGetOnlyTheirOwnIssuedValueAndNoneCrossesInTheClear` proves it on a NATS + server with a stand-in forge. +- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the + software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). From the controller alone, + which knows whether the module's secrets are filled from the vault yet, and with a `why`, said in the vault's + log. Who asked is the `by` the controller names, beside the controller; both are kept (`changed-by`, + `changed-via`). The controller's `rotate ` forwards here for a provider filled the new way (issue + 767): a shared value is made anew for every holder, never for one consumer alone; for a provider whose consumers + keep a login each, `consumer` and `consumer_module` (`--consumer`, `--module`) name the one consumer whose own + login is remade, and no other is touched. + +Names in the store: `own///`, `provision//` and, for a value issued for +one holder, `provision////`. V3 copies the +controller's rows in under these names. + +**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are +filled the new way (its machine's provisioner client asks the vault for them, V4); every other module keeps the +controller's path, so no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision +on issue 675). + +**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheVaultsCallSealedToTheVault` and +`TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses` use a stand-in provider that answers the vault's +account (and, until R1, the controller) and no other caller, and only sealed to the call's key. plex's own tool +answers the controller alone today; it must also answer `.mesh-vault` when it moves with the media catalogue, +after release 57. ## Events @@ -149,11 +243,28 @@ and a holder's ledger file removed only through a person's delete, as here. The lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger file. +**The credential a provider shares with its consumers** (ADR 0349 clause 8, ADR 0158 §4–§6, design 57 V5, issue +677; `pair.go`). One value per provider assignment and provision, `provision//`. Once a minute +the vault reads every plan in the assignments mirror; each plan's `provisions` names the provider serving each +provision its module requires (issue 760). A consumer binds when an entry appears and unbinds when it goes or its +assignment goes; on either, the vault makes one new value for every holder together and says `secret-changed`, and +each holder's client asks again. What the move brought in (V3) is adopted when every pair row of a provider and +provision shares one generation stamp and one value (ADR 0158's shared form), with its origin, and nothing is +remade. Rows without a stamp (ADR 0048, a login per consumer) are never made one value (ADR 0158 §11): `rotate` of +the provision remakes each consumer's own apart, and each consumer's machine is handed only its own row, +`pair////[/]` (the provider's machine all of them). A provider the +vault holds no row of gets its first value in the form each plan entry names (`credential-form`, from the provider's +offer, issue 768): `shared`, one value for every holder; `pair`, a login of its own for each consumer bound; `issued` +or `given` are never made. No form named, plans that disagree, or a form it does not know: nothing is made. An accepted or issued value is never remade, only +its holders recorded. A provision whose plans name a credential per holder (issue 757) has no shared value. **No +value is made that a holder would never get:** while the provider's assignment, or a consumer the move brought in, +is still filled the old way (no plan), every make, remake and rotation is held back and the reason said in the +log. Who holds each value is kept in the store's `shared/` directory, never a value. + ## What comes later -Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move -in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is -empty and the controller still serves every secret. +Taking values in is built (above); the controller's secrets move in with V3; nodes' provisioner clients ask with V4, +for each module handed to the provisioner; providers answer their consumers with B1–B3. ## Tests @@ -161,4 +272,8 @@ empty and the controller still serves every secret. `vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker, -sealed to its key; `secret-changed` carries no value. +sealed to its key; `secret-changed` carries no value. `intake_test.go` is ADR 0349's `secret-ask` test as far as +the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as +the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305 +§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and +accept and rotate. `followups_test.go` holds V1's review follow-ups. diff --git a/modules/mesh-vault/cmd/mesh-vault/askers.go b/modules/mesh-vault/cmd/mesh-vault/askers.go index 44c367c7..2e385c3a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/askers.go +++ b/modules/mesh-vault/cmd/mesh-vault/askers.go @@ -1,12 +1,42 @@ package main -// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11). +import ( + "encoding/json" + "fmt" + "strings" +) + +// Who may ask `get` for which secret (novox/hq ADR 0349 §11, design 57 V4, issue 647). // -// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a -// secret that node's declaration names; or a module, for a secret its manifest declares, only once its -// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are -// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the -// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface. +// The rule is: a node's provisioner client, running as a service under a bus account of its own +// (`provisioner.`, design 57 P3), for a secret that node's declaration names; or a module, for a secret its +// manifest declares, only once its code runs under a bus account of its own (issue 569). The second does not exist +// yet, so the vault answers the first alone (Declared), and refuses every other caller. +// +// **What a node's declaration names, as the vault can read it.** The vault cannot read a node's declaration, which +// the node-engine keeps for root. The controller carries the provisioner's plan of each module it handed to the +// provisioner in that module's assignment, `.` of the assignments mirror (mesh-controller internal/ +// inventory Provisioning), the same value the node's client fills from; its `secrets` are the module's own secrets +// the declaration names for its files there. So a client is handed `own///` only when the +// asker is that node's client, the module is on that node and handed to the provisioner, and its plan names the +// secret. A module only pulled in by another has a value there of its own, which names what requires it (issue 765), +// and is read the same way. Everything else is refused, and the refusal recorded with why (vault.go). +// +// **A value a provider's software issued for one holder** (issue 757): the vault keeps it as +// `provision////` (perholder.go, issue 749). A node's client is handed it when +// the asker is that node's client (perholder.go's rule, before this one) and the module's plan there names that +// provision from that provider among its `provisions`: the credentials of what it requires that its machine's +// client fills from the vault. +// +// **The provision's one credential** (issue 677): `provision//` is handed to a node's client +// when the vault made, adopted or recorded it for a holder on that node (pair.go) and that holder is still there, +// handed to the provisioner: the provider's module on its machine, or a consumer whose plan there names the +// provision from that provider (without a name, which is a credential per holder). +// +// **A consumer's own login** (issue 677's note 12, ADR 0048, ADR 0158 §11): `pair//// +// [/]` is handed to that consumer's machine while its plan there names the provision from that +// provider under that local name, and to the provider's machine while the provider is handed there; never to +// another consumer. // Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not, // why not. @@ -14,13 +44,12 @@ type Askers interface { May(caller, name string) (bool, string) } -// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account. +// NoAsker allows nobody: what a vault with no rule given answers. type NoAsker struct{} // May refuses every caller. func (NoAsker) May(string, string) (bool, string) { - return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " + - "for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)" + return false, "the vault hands a secret to no asker without a rule of who may ask (novox/hq ADR 0349 §11)" } // AllowList is a stand-in rule: each caller, and the names it may have. @@ -35,3 +64,112 @@ func (l AllowList) May(caller, name string) (bool, string) { } return false, "not on the list of who may ask for it" } + +// ProvisionerPrefix begins a node's provisioner client's bus user, `provisioner.` (mesh-controller +// internal/broker, KindProvisioner). +const ProvisionerPrefix = "provisioner." + +// AssignmentsRead is the controller's assignments mirror as this module reads it (its manifest's `reads`). +const AssignmentsRead = "mesh-controller.assignments" + +// AssignmentReader reads one value of the assignments mirror, `.`: nil when there is none. +type AssignmentReader func(key string) ([]byte, error) + +// Declared is the rule the running vault keeps: a node's provisioner client, for a secret of a module on its node +// that the module's plan there names. +type Declared struct { + Read AssignmentReader + // Store is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads. + Store *Store +} + +// plannedSecrets is as much of an assignment's value as the rule reads: the plan's secrets, by name. +type plannedSecrets struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Secrets []struct { + Name string `json:"name"` + } `json:"secrets"` + // Provisions are what the module requires, each with its provider (issue 760); one with a name is a + // credential the machine's client fills from the vault (issue 757). + Provisions []struct { + Name string `json:"name"` + Provision string `json:"provision"` + Provider string `json:"provider"` + } `json:"provisions"` + } `json:"provisioning"` +} + +// May allows caller the secret name when caller is `provisioner.` and name is `own///` +// that the plan of module on node names, or `provision////` whose provision from +// that provider the plan names (issue 757). +func (d Declared) May(caller, name string) (bool, string) { + node, ok := strings.CutPrefix(caller, ProvisionerPrefix) + if !ok || !namePart.MatchString(node) { + return false, "the vault hands a secret only to a node's provisioner client under its own account " + + "(provisioner.); a module's code is answered once it runs under an account of its own (issue 569)" + } + parts := strings.Split(name, "/") + if len(parts) == 3 && parts[0] == "provision" { + if why := sharedRefusal(d.Store, d.Read, node, parts[1], parts[2]); why != "" { + return false, why + } + return true, "" + } + if (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" { + if why := pairRowRefusal(d.Read, node, parts); why != "" { + return false, why + } + return true, "" + } + var holderNode, module, secret, provider string + switch { + case len(parts) == 4 && parts[0] == "own": + holderNode, module, secret = parts[1], parts[2], parts[3] + case len(parts) == 5 && parts[0] == "provision": + provider, secret, holderNode, module = parts[1], parts[2], parts[3], parts[4] + default: + return false, fmt.Sprintf("a node's provisioner client is handed a module's own secret, own/%s//, "+ + "the value a provider issued for one of its modules, provision///%s/, or "+ + "a provision's one credential, provision//; %s is none of them", node, node, name) + } + if holderNode != node { + return false, fmt.Sprintf("%s is a secret of machine %s, and %s asks for machine %s alone", name, holderNode, + caller, node) + } + if d.Read == nil { + return false, "the vault cannot read the assignments, so it cannot tell what " + node + "'s declaration names" + } + raw, err := d.Read(node + "." + module) + if err != nil { + return false, fmt.Sprintf("the assignment %s.%s could not be read, so what it names is not known: %v", node, + module, err) + } + if raw == nil { + return false, fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", module, + node) + } + var a plannedSecrets + if err := json.Unmarshal(raw, &a); err != nil || a.Node != node || a.Module != module { + return false, fmt.Sprintf("the assignment %s.%s is not the controller's", node, module) + } + if a.Provisioning == nil { + return false, fmt.Sprintf("%s on %s is not handed to the provisioner, so its secrets reach it in its "+ + "declaration (novox/hq ADR 0349 §17)", module, node) + } + if provider != "" { + for _, p := range a.Provisioning.Provisions { + if p.Name != "" && p.Provision == secret && p.Provider == provider { + return true, "" + } + } + return false, fmt.Sprintf("%s's plan on %s names no credential of %s from %s", module, node, secret, provider) + } + for _, s := range a.Provisioning.Secrets { + if s.Name == secret { + return true, "" + } + } + return false, fmt.Sprintf("%s's plan on %s names no secret %s", module, node, secret) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/core.go b/modules/mesh-vault/cmd/mesh-vault/core.go new file mode 100644 index 00000000..985fe26d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/core.go @@ -0,0 +1,142 @@ +package main + +// The core's own credentials (novox/hq ADR 0349 §16, issue 763). The bus, the node-engine, the controller and +// mesh-provisioner need their files before any provisioner client runs, so their credentials travel in the +// declaration as values the vault sealed to the node they run on, which the controller carries and cannot open. +// +// **The controller is answered for a core module's own secret alone, and only sealed to that node's key.** The +// controller asks `get` for own/// with the key to seal to; the vault seals only when that +// key is the sealing key the node's own engine stated in its own name (`node.`, at the move: move.go). The +// controller names the key, so that a key it knows to be stale is refused loudly rather than answered with a box +// nobody opens; but it can never choose one, so a value it asks for is never sealed to a key it holds. +// +// **Where the node's key comes from.** Only from the node-engine of that machine, never from the controller's +// record of it, which is the asker's own word. A machine whose engine stated none is answered nothing. + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" +) + +// CoreModules are the core's modules whose own secrets the controller carries sealed by the vault: the same fixed +// list as the controller's (ADR 0349 §16). The node-engine and the controller have no own secrets of their own here. +var CoreModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"} + +// nodeKeyFile is a machine's sealing key as its engine stated it. +type nodeKeyFile struct { + Node string `json:"node"` + Key string `json:"key"` + By string `json:"by"` + Since time.Time `json:"since"` +} + +func (s *Store) nodeKeyPath(node string) string { + return filepath.Join(s.dir, "nodes", node+".json") +} + +// NodeKey is the sealing key node's engine stated (a raw X25519 public key in base64), and whether there is one. +func (s *Store) NodeKey(node string) (string, bool, error) { + if !namePart.MatchString(node) { + return "", false, fmt.Errorf("%q is not a machine's name", node) + } + raw, err := os.ReadFile(s.nodeKeyPath(node)) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + var f nodeKeyFile + if err := json.Unmarshal(raw, &f); err != nil || f.Node != node || f.Key == "" { + return "", false, fmt.Errorf("the record of %s's sealing key is not readable", node) + } + return f.Key, true, nil +} + +func (s *Store) setNodeKey(node, key, by string, at time.Time) error { + if err := os.MkdirAll(filepath.Join(s.dir, "nodes"), 0o700); err != nil { + return err + } + raw, _ := json.MarshalIndent(nodeKeyFile{Node: node, Key: key, By: by, Since: at}, "", " ") + return writeWhole(s.nodeKeyPath(node), append(raw, '\n')) +} + +// sealingKey checks a sealing key a node-engine states: one raw X25519 public key in base64, never a PEM, which a +// private key could be. +func sealingKey(key string) (string, error) { + key = strings.TrimSpace(key) + if privateKeyIn(key) || strings.Contains(key, "-----") { + return "", errors.New("a machine's sealing key is stated as its raw X25519 public key in base64, never a PEM " + + "and never a private key; nothing was taken") + } + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return "", errors.New("the sealing key stated is not a raw X25519 public key in base64; nothing was taken") + } + return key, nil +} + +// recordNodeKey keeps the sealing key node's engine stated, saying so when it replaces another. +func (v *Vault) recordNodeKey(node, key, caller string) error { + held, had, err := v.Store.NodeKey(node) + if err != nil { + return err + } + if had && held == key { + return nil + } + if err := v.Store.setNodeKey(node, key, caller, v.Now().UTC()); err != nil { + return fmt.Errorf("%s's sealing key could not be recorded: %w", node, err) + } + if had { + v.log("%s's sealing key is now %s, stated by %s (it was %s): the core's credentials are sealed to it from now on", + node, key, caller, held) + } else { + v.log("%s's sealing key is %s, stated by %s", node, key, caller) + } + return nil +} + +// coreRefusal says why the controller may not have name sealed to key, or "" when it may: name is a core module's +// own secret and key is its machine's sealing key as that machine's engine stated it. +func (v *Vault) coreRefusal(name, key string) string { + parts := strings.Split(name, "/") + if len(parts) != 4 || parts[0] != "own" { + return fmt.Sprintf("the controller is handed a core module's own secret alone, own//<%s>/, sealed "+ + "to its machine (ADR 0349 §16); %s is not one", strings.Join(CoreModules, "|"), name) + } + node, module := parts[1], parts[2] + if !slices.Contains(CoreModules, module) { + return fmt.Sprintf("%s is not one of the core's modules (%s): its secrets reach its machine through the "+ + "provisioner or its declaration, never through the controller's ask (ADR 0349 §16)", module, + strings.Join(CoreModules, ", ")) + } + stated, held, err := v.Store.NodeKey(node) + if err != nil { + return fmt.Sprintf("%s's sealing key cannot be read: %v", node, err) + } + if !held { + return fmt.Sprintf("%s's node-engine has stated no sealing key to the vault (it does so at the move), so "+ + "there is no key of that machine's own to seal %s to", node, name) + } + want, err := publicPEM(stated) + if err != nil { + return fmt.Sprintf("%s's sealing key as recorded is not usable: %v", node, err) + } + named, err := askerKey(key) + if err != nil { + return err.Error() + } + if named != want { + return fmt.Sprintf("%s is sealed only to %s's sealing key as its node-engine stated it, and the key named "+ + "is another", name, node) + } + return "" +} diff --git a/modules/mesh-vault/cmd/mesh-vault/core_test.go b/modules/mesh-vault/cmd/mesh-vault/core_test.go new file mode 100644 index 00000000..0e98843e --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/core_test.go @@ -0,0 +1,278 @@ +package main + +// Issue 763 (ADR 0349 §16, the lead's decision on issue 755): the core's own credentials travel in the declaration +// sealed by the vault to their node, which the controller carries and cannot open. The vault answers the controller +// for own/// alone, sealed only to the sealing key that node's engine stated in its own name +// at the move; any other module, any other key and any other caller are refused. + +import ( + "crypto/ecdh" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +// aSealingKey is a machine's sealing key as its node-engine makes it: the public half raw in base64, the form it +// states, and the private half as the PKCS #8 PEM the ADR 0206 box opens with. +func aSealingKey(t *testing.T) (public, privatePEM string) { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), + string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +func pemOf(t *testing.T, raw string) string { + t.Helper() + p, err := publicPEM(raw) + if err != nil { + t.Fatal(err) + } + return p +} + +const brokerValue = "pv-anchors-provisioner-broker-password-2025" + +func clientsBroker(sealingKey string) MoveOf { + return MoveOf{Node: "anchor", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", + Generation: "g-1", SealingKey: sealingKey} +} + +// The list is the controller's (mesh-controller's core list, ADR 0349 §16): named here so a change to either is seen. +func TestTheCoreIsTheBusTheProvisionerAndItsClient(t *testing.T) { + if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreModules, want) { + t.Fatalf("the core's modules are %v, want %v", CoreModules, want) + } +} + +// A node-engine states its machine's sealing key in its own name at the move, and only its own machine's. +func TestAMoveRecordsTheSealingKeyItsNodeEngineStates(t *testing.T) { + v := newVault(t, nil) + pub, _ := aSealingKey(t) + if _, err := moveIn(t, v, "node.anchor", clientsBroker(pub), brokerValue); err != nil { + t.Fatal(err) + } + if got, held, err := v.Store.NodeKey("anchor"); err != nil || !held || got != pub { + t.Fatalf("anchor's key is %q, %v, %v", got, held, err) + } + if _, held, _ := v.Store.NodeKey("laptop"); held { + t.Error("a key was recorded for a machine that stated none") + } + // A PEM, which a private key could be, is never taken as a sealing key; nor anything not 32 bytes. + _, private := aSealingKey(t) + for _, bad := range []string{private, pemOf(t, pub), "c2hvcnQ="} { + if _, err := v.MoveOpen("node.anchor", clientsBroker(bad)); err == nil { + t.Errorf("the move took %q as a sealing key", bad[:8]) + } + } + if got, _, _ := v.Store.NodeKey("anchor"); got != pub { + t.Error("a refused key replaced the one stated") + } +} + +// coreOnABus is a vault holding anchor's core credential and a leaf module's secret, both moved in by anchor's +// node-engine, which stated anchor's sealing key; the controller asks it on a real bus. Answers the controller's +// ask (the box as answered), and every byte that crossed. +func coreOnABus(t *testing.T) (ask func(name, key string) (Answer, error), anchorPub, anchorPriv string, + heard func() string) { + t.Helper() + const conf = `listen: 127.0.0.1:-1 +authorization { + users = [ + { user: "novox.mesh-vault", password: "pw", permissions: { + publish: { deny: [">"] } + subscribe: { allow: ["mesh.seat.mesh-vault.call.get.*"] } + allow_responses: true } } + { user: "controller", password: "pw", permissions: { + publish: { allow: ["mesh.seat.mesh-vault.call.get.controller"] } + subscribe: { allow: ["_INBOX.controller.>"] } } } + { user: "tap", password: "pw", permissions: { publish: { deny: [">"] }, subscribe: { allow: [">"] } } } + ] +} +` + file := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(file, []byte(conf), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(file) + if err != nil { + t.Fatal(err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + url := s.ClientURL() + + var mu sync.Mutex + var crossed []string + tap := connectAs(t, url, "tap") + if _, err := tap.Subscribe(">", func(m *nats.Msg) { + mu.Lock() + crossed = append(crossed, m.Subject+" "+string(m.Data)) + mu.Unlock() + }); err != nil { + t.Fatal(err) + } + _ = tap.Flush() + + v := newVault(t, Declared{Read: assignments{}.read}) + anchorPub, anchorPriv = aSealingKey(t) + if _, err := moveIn(t, v, "node.anchor", clientsBroker(anchorPub), brokerValue); err != nil { + t.Fatal(err) + } + if _, err := moveIn(t, v, "node.anchor", superuser(), movedValue); err != nil { + t.Fatal(err) + } + + vaultConn := connectAs(t, url, "novox.mesh-vault") + if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) { + parts := strings.Split(m.Subject, ".") + var args struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` + } + _ = json.Unmarshal(m.Data, &args) + answer := busAnswer{} + got, err := v.Get(untoken(parts[len(parts)-1]), args.Name, args.PublicKey) + if err != nil { + answer.Error = err.Error() + } else { + answer.Result, _ = json.Marshal(got) + } + raw, _ := json.Marshal(answer) + _ = m.Respond(raw) + }); err != nil { + t.Fatal(err) + } + _ = vaultConn.Flush() + + controller := connectAs(t, url, "controller") + ask = func(name, key string) (Answer, error) { + raw, _ := json.Marshal(map[string]string{"name": name, "public_key": key}) + msg, err := controller.Request("mesh.seat.mesh-vault.call.get.controller", raw, 3*time.Second) + if err != nil { + return Answer{}, err + } + var a busAnswer + if err := json.Unmarshal(msg.Data, &a); err != nil { + t.Fatal(err) + } + if a.Error != "" { + return Answer{}, errString(a.Error) + } + var got Answer + if err := json.Unmarshal(a.Result, &got); err != nil { + t.Fatal(err) + } + return got, nil + } + heard = func() string { + _ = tap.Flush() + time.Sleep(100 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + return strings.Join(crossed, "\n") + } + return ask, anchorPub, anchorPriv, heard +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func TestOnARealBusTheControllerGetsACoreCredentialSealedOnlyToItsNodesKey(t *testing.T) { + ask, anchorPub, anchorPriv, heard := coreOnABus(t) + + got, err := ask("own/anchor/mesh-provisioner-client/provisioner-broker", pemOf(t, anchorPub)) + if err != nil { + t.Fatal(err) + } + if got.Name != "own/anchor/mesh-provisioner-client/provisioner-broker" || got.Generation != 1 { + t.Errorf("answered %+v", got) + } + opened, err := seal.Open(got.Sealed, anchorPriv) + if err != nil || opened != brokerValue { + t.Fatalf("anchor's key opens %q, %v", opened, err) + } + + // The controller's own key, or any key but anchor's, is refused: it carries the box and never opens it. + mine, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + if got, err := ask("own/anchor/mesh-provisioner-client/provisioner-broker", mine.PublicKey); err == nil { + t.Errorf("the controller was handed anchor's core credential sealed to its own key: %+v", got) + } else if !strings.Contains(err.Error(), "sealing key") { + t.Errorf("refused for another reason: %v", err) + } + // A module that is not the core's is refused, though anchor's key is the one named. + if got, err := ask("own/anchor/postgres/superuser-password", pemOf(t, anchorPub)); err == nil { + t.Errorf("the controller was handed a leaf module's secret: %+v", got) + } else if !strings.Contains(err.Error(), "core") { + t.Errorf("refused for another reason: %v", err) + } + // A machine whose engine stated no key: nothing to seal to, so nothing is handed. + laptopPub, _ := aSealingKey(t) + if _, err := ask("own/laptop/nats/broker", pemOf(t, laptopPub)); err == nil { + t.Error("the controller was handed a core credential for a machine whose engine stated no key") + } + + all := heard() + if !strings.Contains(all, "mesh.seat.mesh-vault.call.get.controller") { + t.Fatalf("the listener heard nothing of the asks:\n%s", all) + } + for _, v := range []string{brokerValue, movedValue} { + for _, form := range []string{v, base64.StdEncoding.EncodeToString([]byte(v))} { + if strings.Contains(all, form) { + t.Fatal("a value crossed the bus in the clear") + } + } + } +} + +// Only the controller is answered by this rule: a machine's client asking for a core credential, which no plan +// names, is still refused by the running rule, and a node-engine is not an asker. +func TestTheCoreRuleAnswersTheControllerAlone(t *testing.T) { + v := newVault(t, Declared{Read: assignments{}.read}) + pub, _ := aSealingKey(t) + if _, err := moveIn(t, v, "node.anchor", clientsBroker(pub), brokerValue); err != nil { + t.Fatal(err) + } + for _, caller := range []string{"provisioner.anchor", "node.anchor", "anchor.mesh-provisioner-client"} { + if _, err := v.Get(caller, "own/anchor/mesh-provisioner-client/provisioner-broker", pemOf(t, pub)); err == nil { + t.Errorf("%s was handed a core credential", caller) + } + } + // The controller is answered for nothing else: not a pair credential, not a provision's. + for _, name := range []string{"pair/anchor/postgres/anchor/gitea", "provision/anchor/postgres"} { + if _, err := v.Get(ControllerCaller, name, pemOf(t, pub)); err == nil { + t.Errorf("the controller was handed %s", name) + } + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/followups_test.go b/modules/mesh-vault/cmd/mesh-vault/followups_test.go new file mode 100644 index 00000000..aa5fbb8d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/followups_test.go @@ -0,0 +1,162 @@ +package main + +// The four follow-ups V1's third review (review 263 on mesh-catalog#286) left for this package: the tool layer +// passes an act's words through; a rolled record of hand-outs is said in the journal; the export's fingerprint +// and vault_status are what they say; and an act's unproven `by` is kept beside the proven caller. + +import ( + "fmt" + "sort" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +func anyOf(list []string) []any { + out := make([]any, len(list)) + for i, s := range list { + out[i] = s + } + return out +} + +// (1) and (4): the retirement tools pass consumers, why, by, via and confirm through to the custody ledger, and +// a `by` naming somebody other than the proven caller is kept beside it. +func TestTheRetirementToolsPassTheActThrough(t *testing.T) { + c := newCustody(t) + c.ask(apps(5)) + c.passes(1) + c.ask(keepOnly(apps(5), 1)) + c.stable() + set := waitingNames(c.Retirement()) + sort.Strings(set) + defs := definitions(&Env{Custody: c.Custody}) + approve := toolNamed(t, defs, "provisioner_retire_approve") + if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil { + t.Error("the tool took a set other than the one waiting") + } + got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone", + "by": "person.jochen", "via": "telegram"}) + if err != nil { + t.Fatal(err) + } + if done := got.(map[string]any)["retired"].([]string); len(done) != 4 { + t.Errorf("retired %v", done) + } + last := c.bus.events[len(c.bus.events)-5]["body"].(map[string]any) + for _, e := range c.bus.events { + if b := e["body"].(map[string]any); b["change"] == "approved" { + last = b + } + } + if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" { + t.Errorf("the approval said %v", last) + } + + // Delete: consumer and confirm through, the act's words with them. + r := retiredC(t) + del := toolNamed(t, definitions(&Env{Custody: r.Custody}), "provisioner_delete") + if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "c-app", "why": "x"}); err == nil { + t.Error("a confirm naming another was taken through the tool") + } + if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "d-app", "why": "gone"}); err != nil { + t.Fatal(err) + } + body := r.bus.events[len(r.bus.events)-1]["body"].(map[string]any) + if body["change"] != "deleted" || body["by"] != "person.jochen" || body["via"] != nil || body["why"] != "gone" { + t.Errorf("the deletion said %v", body) + } + + // Reject, by the proven caller alone: nothing more is added to via. + k := newCustody(t) + k.ask(apps(5)) + k.passes(1) + k.ask(keepOnly(apps(5), 1)) + k.stable() + waiting := waitingNames(k.Retirement()) + if _, err := toolNamed(t, definitions(&Env{Custody: k.Custody}), "provisioner_retire_reject").runAs("person.jochen", + map[string]any{"consumers": anyOf(waiting), "why": "they come back"}); err != nil { + t.Fatal(err) + } + if body := k.bus.events[len(k.bus.events)-1]["body"].(map[string]any); body["by"] != "person.jochen" || body["via"] != nil { + t.Errorf("the rejection said %v", body) + } +} + +// (2): a roll of the record of hand-outs is said in the journal, loudly. +func TestARolledRecordOfHandOutsIsSaid(t *testing.T) { + v := newVault(t, nil) + var mu sync.Mutex + var said []string + v.Log = func(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + said = append(said, fmt.Sprintf(format, args...)) + } + v.Store.HandedMax = 600 + asker, _ := seal.GenerateKeyPair() + for i := 0; i < 20; i++ { + _, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey) + } + rolled := 0 + for _, s := range said { + if strings.Contains(s, "THE RECORD OF HAND-OUTS WAS ROLLED") { + rolled++ + } + } + if rolled == 0 || rolled != v.Store.Rolled() { + t.Errorf("rolled %d times, said %d times", v.Store.Rolled(), rolled) + } +} + +// (3): the vault's export names the operator key's fingerprint as the controller's secrets.Fingerprint says it, +// and vault_status says what needs a person. +func TestTheExportsFingerprintAndVaultStatus(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { + t.Fatal(err) + } + answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + doc := answer.(map[string]any) + // sha256 of the key, its first eight bytes in hex: the controller's secrets.Fingerprint. + if fp := doc["fingerprint"].(string); !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+16 || + fp != keyFingerprint(v.operatorPublic) || doc["operator-key"] != v.operatorPublic { + t.Errorf("the export says %v", doc) + } + if keyFingerprint("") != "" { + t.Error("no key has a fingerprint") + } + + env := &Env{Vault: v.Vault} + status := func() map[string]any { + got, err := toolNamed(t, definitions(env), "vault_status").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + return got.(map[string]any) + } + if s := status(); s["ok"] != true || s["copies_on_an_earlier_key"] != 0 || s["operator_key"] != v.operatorPublic { + t.Errorf("a vault in order says %v", s) + } + env.refuseKey("a private key was offered") + if s := status(); s["ok"] != false || s["operator_key_refused"] != "a private key was offered" { + t.Errorf("a refused key says %v", s) + } + env.refuseKey("") + // A new operator key whose resealing fails leaves copies on the earlier key, and the status says so. + pub, _ := operatorKey(t) + v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") } + _, _ = v.SetOperatorKey(asPEM(t, pub), "the test") + v.Store.failWrite = nil + if s := status(); s["ok"] != false || s["copies_on_an_earlier_key"] != 1 { + t.Errorf("a copy on an earlier key says %v", s) + } + if s, _ := toolNamed(t, definitions(&Env{}), "vault_status").Run(map[string]any{}); s.(map[string]any)["ready"] != false { + t.Errorf("no vault says %v", s) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/form_test.go b/modules/mesh-vault/cmd/mesh-vault/form_test.go new file mode 100644 index 00000000..7c7d6e1d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/form_test.go @@ -0,0 +1,221 @@ +package main + +// A provider the vault holds no row of yet gets its first value in the form its offer declares (issue 768): the +// controller names the form in each consumer's plan entry (`credential-form`, from the provider's manifest), and +// the vault reads it strictly. A provider the vault already holds rows of is unchanged by it. + +import ( + "reflect" + "strings" + "testing" +) + +func TestANewSharedProviderGetsOneFirstValueForEveryHolder(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+shared"), + } + v := pairVault(t, mirror) + if o := pass(t, v, standIn); o.Outcome != PairMade || o.Generation != 1 { + t.Fatalf("a new shared provider: %+v", o) + } + value, _, err := asks(v, "provisioner.anchor", standIn) + if err != nil || value == "" { + t.Fatalf("the provider's machine was refused its first value: %v", err) + } + for _, c := range []string{"provisioner.g14", "provisioner.ace"} { + if got, _, err := asks(v, c, standIn); err != nil || got != value { + t.Fatalf("%s got %q (%v), not the one value", c, got, err) + } + } + if changedSaid(t, v, standIn, value) != 1 { + t.Fatal("secret-changed was not said once for the first value") + } + rows, _ := v.pairRows() + if len(rows[pairKey{"anchor", "stand-in-api"}]) != 0 { + t.Fatal("a login per consumer was made for a provider whose offer shares one credential") + } + if o := pass(t, v, standIn); o.Outcome != PairUnchanged { + t.Fatalf("the pass after the first value: %+v", o) + } +} + +func TestANewPerLoginProviderGetsAFirstLoginOfItsOwnForEachConsumer(t *testing.T) { + const pg = "provision/novox/postgres-database" + mirror := assignments{ + "novox.postgres": handedWith("novox", "postgres"), + "g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres+pair"), + "ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres#stats+pair"), + } + v := pairVault(t, mirror) + if o := pass(t, v, pg); o.Outcome != PairMade { + t.Fatalf("a new per-login provider: %+v", o) + } + if _, held, _ := v.Store.Read(pg); held { + t.Fatal("one shared value was made for a provider whose consumers each keep a login of their own") + } + gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami/stats" + ofGitea, _, err := asks(v, "provisioner.g14", gitea) + if err != nil || ofGitea == "" { + t.Fatalf("gitea's machine was refused its first login: %v", err) + } + ofUmami, _, err := asks(v, "provisioner.ace", umami) + if err != nil || ofUmami == "" { + t.Fatalf("umami's machine was refused its first login: %v", err) + } + if ofGitea == ofUmami { + t.Fatal("two consumers of a per-login provider were given one value") + } + if _, _, err := asks(v, "provisioner.g14", umami); err == nil { + t.Fatal("gitea's machine was handed umami's login") + } + for name, want := range map[string]string{gitea: ofGitea, umami: ofUmami} { + if got, _, err := asks(v, "provisioner.novox", name); err != nil || got != want { + t.Fatalf("the provider's machine got %q for %s (%v)", got, name, err) + } + } + // The logins are rows now: the next pass remakes none of them. + if o := pass(t, v, pg); o.Outcome != PairPerPair { + t.Fatalf("the pass after the first logins: %+v", o) + } + if got, gen, _ := asks(v, "provisioner.g14", gitea); got != ofGitea || gen != 1 { + t.Fatal("a consumer's login was remade by the next pass") + } +} + +// A provider the vault holds rows of keeps what it had, whatever form the plans name. +func TestAProviderWithRowsIsUnchangedByTheFormItsPlansName(t *testing.T) { + t.Run("stamped rows", func(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "the-value-they-share", "stamp-1", OriginMade) + if o := pass(t, v, standIn); o.Outcome != PairAdopted { + t.Fatalf("stamped rows: %+v", o) + } + if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 { + t.Fatal("the adopted value is not the move's") + } + }) + t.Run("logins of their own", func(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+pair"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade) + movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade) + if o := pass(t, v, standIn); o.Outcome != PairPerPair { + t.Fatalf("unstamped rows: %+v", o) + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatal("a shared value was made for a provider whose rows are logins of their own") + } + if got, gen, _ := asks(v, "provisioner.g14", "pair/anchor/stand-in-api/g14/app"); got != "login-of-app" || + gen != 1 { + t.Fatal("a consumer's own login changed") + } + }) +} + +// A form the vault does not make (issued by the provider's software, or given by a person), a form it does not know, +// or plans that disagree: nothing is made, and the pass says why. The rule that no holder is left behind still holds. +func TestTheVaultMakesNothingForAFormItDoesNotMakeOrCannotTrust(t *testing.T) { + for form, said := range map[string]string{ + "issued": "issued by the provider's software", + "given": "given by a person", + "one-for-all": "which is not a credential form", + "shared|pair": "disagree", + "shared|": "no plan says whether", + "shared@unmoved": "stand-in on anchor", + } { + t.Run(form, func(t *testing.T) { + provider := handedWith("anchor", "stand-in") + first, second := form, form + if a, b, ok := strings.Cut(form, "|"); ok { + first, second = a, b + } + if a, _, ok := strings.Cut(form, "@"); ok { + first, second, provider = a, a, unmoved("anchor", "stand-in") + } + entry := func(f string) string { + if f == "" { + return "stand-in-api@anchor/stand-in" + } + return "stand-in-api@anchor/stand-in+" + f + } + mirror := assignments{ + "anchor.stand-in": provider, + "g14.app": handedWith("g14", "app", entry(first)), + "ace.other": handedWith("ace", "other", entry(second)), + } + v := pairVault(t, mirror) + if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, said) { + t.Fatalf("form %s: %+v", form, o) + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatalf("form %s: a shared value was made", form) + } + if rows, _ := v.pairRows(); len(rows[pairKey{"anchor", "stand-in-api"}]) != 0 { + t.Fatalf("form %s: a login was made", form) + } + }) + } +} + +// A consumer that binds after the move to a per-login provider the vault already holds rows of gets a login of its +// own (issue 771): the rows that exist are never touched, and `secret-changed` is said for the new login only. +func TestANewConsumerOfAPerLoginProviderWithRowsGetsALoginOfItsOwn(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+pair"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+pair"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade) + movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade) + app, other := "pair/anchor/stand-in-api/g14/app", "pair/anchor/stand-in-api/ace/other" + third := "pair/anchor/stand-in-api/novox/third" + before, said := map[string]Record{}, map[string]int{} + for _, n := range []string{app, other} { + r, _, _ := v.Store.Read(n) + before[n], said[n] = r, changedSaid(t, v, n, "") + } + + mirror["novox.third"] = handedWith("novox", "third", "stand-in-api@anchor/stand-in+pair") + if o := pass(t, v, standIn); o.Outcome != PairMade { + t.Fatalf("a third consumer binding: %+v", o) + } + got, _, err := asks(v, "provisioner.novox", third) + if err != nil || got == "" || got == "login-of-app" || got == "login-of-other" { + t.Fatalf("the third consumer has no login of its own: %q (%v)", got, err) + } + if got, _, err := asks(v, "provisioner.anchor", third); err != nil || got == "" { + t.Fatalf("the provider's machine was refused the new login: %v", err) + } + for n, was := range before { + now, _, _ := v.Store.Read(n) + if !reflect.DeepEqual(now, was) { + t.Fatalf("%s was touched: %+v, was %+v", n, now, was) + } + if changedSaid(t, v, n, "") != said[n] { + t.Fatalf("secret-changed was said for %s", n) + } + } + if changedSaid(t, v, third, "") != 1 { + t.Fatal("secret-changed was not said once for the new login") + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatal("a shared value was made") + } + if o := pass(t, v, standIn); o.Outcome != PairPerPair { + t.Fatalf("the pass after the new login: %+v", o) + } + if got2, _, _ := asks(v, "provisioner.novox", third); got2 != got { + t.Fatal("the new login was remade by the next pass") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go new file mode 100644 index 00000000..d7898574 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -0,0 +1,760 @@ +package main + +// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305 +// with the vault in the controller's place; design 57's V2, issue 675). +// +// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the +// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`. +// +// **A value comes in sealed to a key the vault made for that one call, and opens only here.** Each call opens with +// the public half of a key made for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's +// prompt and a provider's tool already seal to) and ends with what was sealed to that key, which the vault opens +// with the private half: it never leaves this process and is forgotten once used or after two minutes. +// +// **The vault asks the desk and the provider itself** (issue 723). The desk's prompt and a provider's issuing tool +// are asked by the vault, in its own name, under the bus account its own runtime runs on (ADR 0259 §8's runs-as): +// the bus lets that account alone call them naming the vault, so the prompt and the tool know the vault asks, and +// what they seal to the call's key opens here and nowhere else (ADR 0349 clauses 9 and 10, ADR 0277 §23). The +// controller asks `secret-ask` and `issued` once, carrying no key and no sealed value; it reaches the desk and the +// tool itself only for a module still filled the old way, until R1 (issue 682). Only `accept` stays two steps: +// the value is given at the controller's terminal, which seals it there to the call's key. +// +// Which secret each one is, by name in the vault's store: +// +// own/// a module's own secret on one machine +// provision// the one credential of a provider assignment and provision (clause 8) + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "golang.org/x/crypto/nacl/box" +) + +// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName). +const ControllerCaller = "controller" + +// Where a value came from. +const ( + OriginMade = "made" + OriginAccepted = "accepted" + OriginTyped = "typed" + OriginIssued = "issued" +) + +// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits. +const CallOpenFor = 2 * time.Minute + +// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now). +const ( + // SecretAskOpenFor is how long an ask that has not ended counts as open. + SecretAskOpenFor = 2 * time.Minute + // SecretAsksPerHour is how many asks for one secret an hour takes. + SecretAsksPerHour = 3 +) + +var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + +// OwnSecret is the vault's name for a module's own secret on one machine. +func OwnSecret(node, module, name string) (string, error) { + for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "own/" + node + "/" + module + "/" + name, nil +} + +// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision. +func ProvisionSecret(provider, provision string) (string, error) { + for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "provision/" + provider + "/" + provision, nil +} + +// callKey is one call's key and what the call takes in. Never written anywhere. +type callKey struct { + verb, secret, by string + // origin is what an `accept` call keeps the value as: accepted, or issued for a covered account (issue 787). + origin string + // ask is the ask's record for `secret-ask`, which the call's id is. + ask *AskRecord + // move is what `move-in` takes: the row of the controller's store (move.go). + move *MoveOf + public [32]byte + private [32]byte + until time.Time +} + +// Opened is a first step's answer: the call, and the key to seal its value to. Never a value. +type Opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + Secret string `json:"secret"` + AskedBy string `json:"asked_by,omitempty"` +} + +// fromTheController refuses every caller but the controller: only it asks the vault to take a value in, and only +// it carries a value given at its terminal. +func fromTheController(caller, verb string) error { + if caller != ControllerCaller { + if caller == "" { + caller = stdio.Unproven + } + return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+ + "for one call; %s is not the controller", verb, caller) + } + return nil +} + +// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes +// have passed. +func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return "", "", err + } + id := hex.EncodeToString(idRaw) + if ask != nil { + id = ask.ID + } + v.callsMu.Lock() + defer v.callsMu.Unlock() + if v.pending == nil { + v.pending = map[string]*callKey{} + } + now := v.Now() + for k, c := range v.pending { + if now.After(c.until) { + delete(v.pending, k) + } + } + v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv, + until: now.Add(CallOpenFor)} + return id, base64.StdEncoding.EncodeToString(pub[:]), nil +} + +// takeCall ends a call: its key is forgotten whatever follows. +func (v *Vault) takeCall(verb, id string) (*callKey, error) { + v.callsMu.Lock() + defer v.callsMu.Unlock() + c, ok := v.pending[id] + if !ok || c.verb != verb { + return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+ + "started again; open it anew", verb, id) + } + delete(v.pending, id) + if v.Now().After(c.until) { + return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor) + } + return c, nil +} + +// open opens what was sealed to the call's key, and forgets the key. +func (c *callKey) open(sealed string) ([]byte, error) { + defer func() { c.private = [32]byte{} }() + return c.openOne(sealed) +} + +// openOne opens one value sealed to the call's key and keeps the key, for a call that takes several (one per +// holder, perholder.go); the caller forgets the key. +func (c *callKey) openOne(sealed string) ([]byte, error) { + blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed)) + if err != nil { + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private) + if !ok { + // Never the value, never what failed to open: only that it was not sealed to this call. + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + return out, nil +} + +func zero(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// ---- accept ------------------------------------------------------------------------------------------------- + +// CoveredSecrets are the names, in a module's own secrets, of the bus accounts the controller mints for a core module +// or a module marked moved and hands to the vault (novox/hq ADR 0374, issue 787): the module's account on the bus +// (`broker`) and, for the machine's provisioner client, its own (`provisioner-broker`). +var CoveredSecrets = []string{"broker", "provisioner-broker"} + +// Covered says whether secret is a covered account: a module's own secret named one of CoveredSecrets. The vault +// cannot tell core or moved from a module's manifest; the controller, which can, is the only caller that may hand +// one in, and the name keeps what it hands to these. +func Covered(secret string) bool { + parts := strings.Split(secret, "/") + return len(parts) == 4 && parts[0] == "own" && slices.Contains(CoveredSecrets, parts[3]) +} + +// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal +// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value). +// +// **With origin issued it is a covered account the controller minted** (ADR 0374, issue 787): the controller alone +// hands it, for a covered secret alone, and a re-issue replaces the value in place. Without origin it is a value +// given at the terminal, as before. +func (v *Vault) AcceptOpen(caller, secret, by, origin string) (Opened, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Opened{}, err + } + if err := validName(secret); err != nil { + return Opened{}, err + } + kept := OriginAccepted + switch origin { + case "": + if rec, held, err := v.Store.Read(secret); err != nil { + return Opened{}, err + } else if held && rec.Origin == OriginIssued && !Covered(secret) { + return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+ + "it enters the vault (ADR 0305 §10); nothing is taken", secret) + } + case OriginIssued: + if !Covered(secret) { + return Opened{}, fmt.Errorf("%s is not a bus account the controller mints for a core or moved module "+ + "(%s), so it is not handed with origin issued; nothing is taken", secret, strings.Join(CoveredSecrets, ", ")) + } + kept = OriginIssued + default: + return Opened{}, fmt.Errorf("the origin %q is not one accept takes (issued, or none for a value given at the "+ + "terminal); nothing is taken", origin) + } + id, key, err := v.openCall("accept", secret, by, nil) + if err != nil { + return Opened{}, err + } + v.callsMu.Lock() + if c := v.pending[id]; c != nil { + c.origin = kept + } + v.callsMu.Unlock() + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault. +func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("accept", call) + if err != nil { + return Changed{}, err + } + value, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + defer zero(value) + if strings.TrimSpace(string(value)) == "" { + return Changed{}, errors.New("the value given is empty; nothing was taken") + } + origin := c.origin + if origin == "" { + origin = OriginAccepted + } + return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, origin) +} + +// ---- secret-ask --------------------------------------------------------------------------------------------- + +// AskOf is what an ask is for, and where. +type AskOf struct { + Node, Module, Name, Desk string + // AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the + // controller alone. + AskedBy string +} + +// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask +// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to, +// and who asked as the record says it, which the prompt names (§26). +func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Opened{}, err + } + secret, err := OwnSecret(a.Node, a.Module, a.Name) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err) + } + if !namePart.MatchString(a.Desk) { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk) + } + askedBy := askedByName(a.AskedBy) + v.Store.asksMu.Lock() + defer v.Store.asksMu.Unlock() + now := v.Now().UTC() + recent, err := v.Store.AsksSince(now.Add(-time.Hour)) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err) + } + ended := map[string]bool{} + for _, r := range recent { + if r.Event == AskEnded { + ended[r.ID] = true + } + } + lastHour, openBy := 0, "" + for _, r := range recent { + if r.Event != AskOpened || r.Secret != secret { + continue + } + lastHour++ + if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) { + openBy = r.AskedBy + } + } + if openBy != "" { + return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+ + "secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor) + } + if lastHour >= SecretAsksPerHour { + return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+ + "for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour) + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return Opened{}, err + } + rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node, + Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller} + if err := v.recordAsk(rec); err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err) + } + id, key, err := v.openCall("secret-ask", secret, askedBy, &rec) + if err != nil { + _ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"}) + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil +} + +// The outcomes an ask ends with, as the controller's record had them. +var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true} + +// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with +// the outcome the desk answered instead. How it ended is recorded whatever happens. +func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Changed{}, err + } + if sealed == "" && !askOutcomes[outcome] { + return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " + + "timed-out, empty, refused or failed") + } + c, err := v.takeCall("secret-ask", call) + if err != nil { + return Changed{}, err + } + end := func(how string) { + v.Store.asksMu.Lock() + defer v.Store.asksMu.Unlock() + if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret, + Outcome: how}); err != nil { + v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err) + } + } + if sealed == "" { + end(outcome) + return Changed{Name: c.secret, Unchanged: true}, nil + } + opened, err := c.open(sealed) + if err != nil { + end("failed") + return Changed{}, err + } + value := strings.TrimRight(string(opened), "\r\n") + zero(opened) + if strings.TrimSpace(value) == "" { + end("empty") + return Changed{}, fmt.Errorf("%w: the prompt on %s was answered empty", ErrNothingGiven, c.ask.Desk) + } + changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped) + value = "" + if err != nil { + end("failed") + return Changed{}, err + } + end("given") + return changed, nil +} + +// recordAsk appends one line to the record of asks. The caller holds Store.asksMu, so no two writers roll the +// record at once and no ask is counted against its bounds while another line is half written. +func (v *Vault) recordAsk(r AskRecord) error { + rolled, err := v.Store.RecordAsk(r) + if rolled { + v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+ + "dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept) + } + return err +} + +// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the +// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own +// reach the prompt. +func askedByName(caller string) string { + first, _, _ := strings.Cut(caller, ",") + var b strings.Builder + for _, r := range strings.TrimSpace(first) { + if b.Len() >= 80 { + break + } + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@', + r == '-', r == ' ': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + name := strings.TrimSpace(b.String()) + if name == "" || strings.HasPrefix(name, "-") { + return "an unnamed caller" + } + return name +} + +// ---- issued ------------------------------------------------------------------------------------------------- + +// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause +// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the +// provider's machine, with the call's key, and passes on what it answered. +func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Opened{}, err + } + secret, err := ProvisionSecret(provider, provision) + if err != nil { + return Opened{}, err + } + if !namePart.MatchString(module) { + return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module) + } + id, key, err := v.openCall("issued", secret, module+" on "+provider, nil) + if err != nil { + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one +// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is. +func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("issued", call) + if err != nil { + return Changed{}, err + } + opened, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + value := strings.TrimSpace(string(opened)) + zero(opened) + if value == "" { + return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by) + } + return v.keep(c.secret, value, c.by, caller, OriginIssued) +} + +// ---- the vault's own calls (issue 723) ----------------------------------------------------------------------- + +// DeskPromptFor is how long the desk's prompt waits for the operator when the vault asks: under the thirty seconds +// a call through the runtime waits, and under the controller's wait for `secret-ask`. +const DeskPromptFor = 20 + +// DeskPrompt is the desk's secret prompt as the vault asks it: the seat's verb on the desk's machine. +func DeskPrompt(desk string) string { return "seat:node-launcher.secret@" + desk } + +// Asked is how an ask at a desk ended: what was kept, or why nothing was. Never the value. +type Asked struct { + Secret string `json:"secret"` + Generation int `json:"generation,omitempty"` + AskedBy string `json:"asked_by"` + // Outcome is given, dismissed, timed-out, empty, refused or failed. + Outcome string `json:"outcome"` + Unchanged bool `json:"unchanged,omitempty"` +} + +// AskAtDesk is `secret-ask` (ADR 0349 clause 9, ADR 0277 §22–§27): asked by the controller alone, which names who +// asked it. The ask is recorded and its bounds held before any prompt opens; the vault asks the desk's prompt +// itself, in its own name, naming the asker as recorded and giving a key made for this one call; what is typed +// comes back sealed to that key and opens here. How the prompt ended is the answer and the record's. +func (v *Vault) AskAtDesk(caller string, a AskOf) (Asked, error) { + if v.Ask == nil { + return Asked{}, errors.New("nobody was asked to type anything: this vault reaches no desk") + } + opened, err := v.AskOpen(caller, a) + if err != nil { + return Asked{}, err + } + out := Asked{Secret: opened.Secret, AskedBy: opened.AskedBy} + end := func(outcome string) (Asked, error) { + out.Outcome = outcome + if _, err := v.AskEnd(caller, opened.Call, "", outcome); err != nil { + return out, err + } + return out, nil + } + raw, err := v.Ask(DeskPrompt(a.Desk), map[string]any{"module": a.Module, "secret": a.Name, "node": a.Node, + "asked_by": opened.AskedBy, "seal_to": opened.SealTo, "timeout_seconds": DeskPromptFor}) + if err != nil { + if _, ended := end("refused"); ended != nil { + v.log("%v", ended) + } + return Asked{}, fmt.Errorf("the desk on %s could not be asked: %w", a.Desk, err) + } + var answer struct { + Sealed string `json:"sealed"` + Cancelled bool `json:"cancelled"` + TimedOut bool `json:"timed_out"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return end("failed") + } + switch { + case answer.TimedOut: + return end("timed-out") + case answer.Cancelled: + return end("dismissed") + case answer.Sealed == "": + return end("failed") + } + changed, err := v.AskEnd(caller, opened.Call, answer.Sealed, "") + if err != nil { + // AskEnd recorded how it ended; an empty answer is the operator's, not a failure. + if errors.Is(err, ErrNothingGiven) { + out.Outcome = "empty" + return out, nil + } + return Asked{}, err + } + out.Outcome, out.Generation, out.Unchanged = "given", changed.Generation, changed.Unchanged + return out, nil +} + +// ErrNothingGiven is a prompt answered empty: the operator's answer, not a failure. +var ErrNothingGiven = errors.New("nothing was given") + +// Issued is what the vault kept of a value a provider's software issued. Never the value. For a tool that answers a +// value per holder (perholder.go), Secret is the provision's name, Holders what was kept for each and NotAnswered +// the holders asked about that the tool gave nothing for; Unchanged is then whether no holder's value changed. +type Issued struct { + Secret string `json:"secret"` + Generation int `json:"generation"` + Unchanged bool `json:"unchanged,omitempty"` + Holders []IssuedHolder `json:"holders,omitempty"` + NotAnswered []Holder `json:"not_answered,omitempty"` +} + +// TakeIssued is `issued` (ADR 0349 clause 10, ADR 0305 with the vault in the controller's place): asked by the +// controller alone, naming the provision and the provider's machine, module and tool. The vault asks that tool +// itself, in its own name, with a key made for this one call; the tool answers its software's value sealed to that +// key, which opens here and is kept as the provision's one credential. The bus lets the vault's account call no +// tool but an issuing one, so a tool named here that is not one is refused there. +func (v *Vault) TakeIssued(caller, provision, provider, module, tool string) (Issued, error) { + return v.TakeIssuedFor(caller, provision, provider, module, tool, nil) +} + +// TakeIssuedFor is TakeIssued naming the provision's holders from that provider, as the controller knows them (issue +// 749): the tool is asked with them, and may answer one value for the provision (`sealed`, kept as its one +// credential, as before) or one per holder (`holders`: node, module and sealed each, kept as that holder's own and +// handed to it alone, perholder.go). Never both. +func (v *Vault) TakeIssuedFor(caller, provision, provider, module, tool string, holders []Holder) (Issued, error) { + if v.Ask == nil { + return Issued{}, errors.New("nothing was taken: this vault reaches no provider") + } + if !toolName.MatchString(tool) { + return Issued{}, fmt.Errorf("the provider's tool %q is not a tool's name", tool) + } + if err := checkHolders(holders); err != nil { + return Issued{}, fmt.Errorf("nothing was asked: %w", err) + } + opened, err := v.IssuedOpen(caller, provision, provider, module) + if err != nil { + return Issued{}, err + } + args := map[string]any{"provision": provision, "seal_to": opened.SealTo} + if len(holders) > 0 { + args["holders"] = holders + } + raw, err := v.Ask(module+"."+tool+"@"+provider, args) + if err != nil { + v.dropCall("issued", opened.Call) + return Issued{}, fmt.Errorf("its tool %s could not be asked: %w", tool, err) + } + var given struct { + Sealed string `json:"sealed"` + Holders []sealedFor `json:"holders"` + } + if err := json.Unmarshal(raw, &given); err != nil || (given.Sealed == "") == (len(given.Holders) == 0) { + v.dropCall("issued", opened.Call) + return Issued{}, fmt.Errorf("its tool %s answered no sealed value, or both one value and a value per holder", tool) + } + if len(given.Holders) > 0 { + return v.takePerHolder(caller, opened.Call, provider, provision, holders, given.Holders) + } + changed, err := v.IssuedTake(caller, opened.Call, given.Sealed) + if err != nil { + return Issued{}, err + } + return Issued{Secret: changed.Name, Generation: changed.Generation, Unchanged: changed.Unchanged}, nil +} + +var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`) + +// dropCall forgets a call's key that will not be answered. +func (v *Vault) dropCall(verb, id string) { + v.callsMu.Lock() + defer v.callsMu.Unlock() + if c, ok := v.pending[id]; ok && c.verb == verb { + c.private = [32]byte{} + delete(v.pending, id) + } +} + +// ---- rotate ------------------------------------------------------------------------------------------------- + +// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a +// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential, +// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is +// rotated like a made one (ADR 0228). Who asked is the person the controller names, beside the controller that +// carried the act — both are kept — and why is said in the vault's log. +// +// **From the controller alone, and with a why** (the review of issue 675): only the controller knows whether a +// module's secrets are filled from the vault yet (ADR 0349 clause 17); a rotation asked of the vault for a module +// still filled the old way would change a value its machine never reads, and drift from the one the controller +// holds. And a rotation is an act by hand, which says why. +func (v *Vault) Rotate(caller, secret, by, why string) (Changed, error) { + return v.RotateFor(caller, secret, Narrow{}, by, why) +} + +// Narrow is the one consumer a rotation of a per-login provider's credential is for (issue 767): the controller's +// `rotate --consumer --module `, either or both. Empty is every consumer. +type Narrow struct { + Consumer string `json:"consumer,omitempty"` + ConsumerModule string `json:"consumer_module,omitempty"` +} + +func (n Narrow) empty() bool { return n.Consumer == "" && n.ConsumerModule == "" } + +func (n Narrow) holds(r pairRow) bool { + return (n.Consumer == "" || r.consumer == n.Consumer) && (n.ConsumerModule == "" || r.module == n.ConsumerModule) +} + +func (n Narrow) String() string { + switch { + case n.Consumer == "": + return n.ConsumerModule + case n.ConsumerModule == "": + return n.Consumer + } + return n.ConsumerModule + " on " + n.Consumer +} + +// RotateFor is Rotate, narrowed to one consumer's own login of a per-login provider (ADR 0158 §11, issue 767). A +// value shared by every holder (ADR 0158) is one value, so it is never rotated for one consumer alone: refused, and +// nothing changes. A module's own secret names no consumer. +func (v *Vault) RotateFor(caller, secret string, one Narrow, by, why string) (Changed, error) { + if caller != ControllerCaller { + return Changed{}, fmt.Errorf("the vault rotates for the controller alone, which knows whether the module's "+ + "secrets are filled from the vault yet; %s is not the controller: `secret rotate` at the controller", caller) + } + if strings.TrimSpace(why) == "" { + return Changed{}, errors.New("not rotated: a rotation says why it was asked (`why`)") + } + if err := validName(secret); err != nil { + return Changed{}, err + } + rec, held, err := v.Store.Read(secret) + if err != nil { + return Changed{}, err + } + parts := strings.Split(secret, "/") + shared := len(parts) == 3 && parts[0] == "provision" + switch { + case !one.empty() && !shared: + return Changed{}, fmt.Errorf("not rotated: %s is not a provision's credential, and only a provider's "+ + "consumers are named in a rotation", secret) + case !one.empty() && held: + return Changed{}, fmt.Errorf("not rotated: %s is one value every holder shares (ADR 0158), so it cannot be "+ + "rotated for %s alone; rotated without a consumer, it is made anew for every holder", secret, one) + case !held && shared: + // A provider whose consumers keep logins of their own: each one's own is remade, never one shared value. + changed, err := v.rotatePerPair(secret, one, someoneOr(by, caller)) + if err == nil { + v.log("rotated each consumer's own login of %s (%d), asked by %s via %s: %s", secret, len(changed.Each), + someoneOr(by, caller), caller, strings.TrimSpace(why)) + } + return changed, err + case !held: + return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret) + case Covered(secret): + return Changed{}, fmt.Errorf("not rotated: %s is a bus account the controller mints and hands here (ADR 0374 "+ + "clause 4): only a password whose hash the bus's user list holds can log in, so the vault makes no new "+ + "value for it; `module issue` again hands a new one and replaces this in place", secret) + case rec.Origin == OriginIssued: + return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+ + "make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now", + secret) + case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"): + return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+ + "vault (ADR 0349 clause 8): accept its new value instead", secret) + } + if shared || (parts[0] == "pair" && (len(parts) == 5 || len(parts) == 6)) { + if why := v.rotationHeldBack(strings.Join(append([]string{"provision"}, parts[1:3]...), "/")); why != "" { + return Changed{}, fmt.Errorf("not rotated: %s", why) + } + } + if by == "" { + by = caller + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return Changed{}, err + } + changed, err := v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade) + if err == nil && shared { + if sh, recorded, rerr := v.Store.ReadShared(secret); rerr == nil && recorded { + sh.Generation, sh.At = changed.Generation, v.Now().UTC() + if werr := v.Store.WriteShared(sh); werr != nil { + v.log("%s was rotated, but the record of who holds it keeps the earlier generation: %v", secret, werr) + } + } + } + if err == nil { + v.log("rotated %s (generation %d), asked by %s via %s: %s", secret, changed.Generation, by, caller, + strings.TrimSpace(why)) + } + return changed, err +} + +func someoneOr(by, caller string) string { + if strings.TrimSpace(by) == "" { + return caller + } + return by +} diff --git a/modules/mesh-vault/cmd/mesh-vault/intake_test.go b/modules/mesh-vault/cmd/mesh-vault/intake_test.go new file mode 100644 index 00000000..e795bad7 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/intake_test.go @@ -0,0 +1,552 @@ +package main + +// ADR 0349's tests for what the vault takes in (design 57's V2, issue 675): the vault's `secret-ask` test, the +// issuing provider's test with a stand-in provider (plex's own tool moves with the media catalogue, after +// release 57), an accepted value opened in the vault alone and never remade, and rotate. +// +// The two-step tests below drive the steps the vault's own calls are made of (owncalls_test.go, issue 723): "the +// controller" there is a test function that holds no private key, standing where the vault's call to the desk or the +// provider stands, and carries only what was sealed to the key the vault made for the call. + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/nacl/box" +) + +// sealToCall seals value to a call's key as the desk's prompt and a provider's tool do: the NaCl anonymous box. +func sealToCall(t *testing.T, sealTo, value string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(sealTo) + if err != nil || len(raw) != 32 { + t.Fatalf("seal_to %q is not a key", sealTo) + } + var key [32]byte + copy(key[:], raw) + sealed, err := box.SealAnonymous(nil, []byte(value), &key, rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(sealed) +} + +// handedTo opens what `get` hands an allowed asker: the value the vault holds. +func handedTo(t *testing.T, v testVault, asker, name string) string { + t.Helper() + key, _ := seal.GenerateKeyPair() + a, err := v.Get(asker, name, key.PublicKey) + if err != nil { + t.Fatal(err) + } + value, err := seal.Open(a.Sealed, key.PrivateKey) + if err != nil { + t.Fatal(err) + } + return value +} + +// everything is all the vault said and wrote during a test, as text. +func everything(t *testing.T, v testVault, more ...any) string { + t.Helper() + var b strings.Builder + for _, said := range v.bus.raw { + b.WriteString(said) + } + for _, content := range filesUnder(t, v.dir) { + b.WriteString(content) + } + for _, m := range more { + raw, _ := json.Marshal(m) + b.Write(raw) + } + return b.String() +} + +const typedAtTheDesk = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" + +func telegramAsk(by string) AskOf { + return AskOf{Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: by} +} + +// ---- the vault's secret-ask test (ADR 0349 "How it is checked") --------------------------------------------- + +// An ask is recorded in the vault's store before the prompt opens; its prompt names the asker as the bus named +// it; the typed value opens only in the vault; the controller carries ciphertext only. +func TestAnAskIsRecordedBeforeThePromptAndTheTypedValueOpensOnlyInTheVault(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}}) + opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code, via the console")) + if err != nil { + t.Fatal(err) + } + // Recorded before any prompt opens: the record holds the ask now, with no end. + asks, _ := v.Store.Asks(10) + if len(asks) != 1 || asks[0].Event != AskOpened || asks[0].ID != opened.Call || asks[0].AskedBy != "g14/claude-code" || + asks[0].Caller != ControllerCaller || asks[0].Desk != "laptop" || asks[0].Secret != "own/anchor/telegram/telegram-token" { + t.Fatalf("the record before the prompt: %+v", asks) + } + // The prompt names the asker as the bus named it, cut to a name: what the vault answers is what it recorded. + if opened.AskedBy != "g14/claude-code" { + t.Errorf("the prompt would name %q", opened.AskedBy) + } + // The desk seals what is typed to the call's key; the controller passes it on. + changed, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, typedAtTheDesk+"\n"), "") + if err != nil { + t.Fatal(err) + } + if changed.Generation != 1 || changed.By != "g14/claude-code" { + t.Errorf("kept as %+v", changed) + } + if got := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); got != typedAtTheDesk { + t.Errorf("the vault holds %q, not what was typed with its line ending taken off", got) + } + rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token") + if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller { + t.Errorf("the record says origin %q, by %q, via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia) + } + asks, _ = v.Store.Asks(10) + if len(asks) != 2 || asks[0].Event != AskEnded || asks[0].Outcome != "given" || asks[0].ID != opened.Call { + t.Errorf("how it ended: %+v", asks) + } + if strings.Contains(everything(t, v, opened, changed, asks), typedAtTheDesk) { + t.Fatal("the typed value is in something the vault said, answered or wrote in the clear") + } +} + +// One open ask per secret, and three an hour (ADR 0277 §24), read from the vault's own record. +func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) { + v := newVault(t, nil) + first, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatal(err) + } + if _, err := v.AskOpen(ControllerCaller, telegramAsk("ace.claude-code")); err == nil || + !strings.Contains(err.Error(), "still open (asked by g14.claude-code)") { + t.Fatalf("a second ask while one is open: %v", err) + } + // Another secret is not held back by it. + other := telegramAsk("ace.claude-code") + other.Name = "webhook-secret" + if _, err := v.AskOpen(ControllerCaller, other); err != nil { + t.Fatalf("another secret's ask was held back: %v", err) + } + if _, err := v.AskEnd(ControllerCaller, first.Call, "", "dismissed"); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + o, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatalf("ask %d of the hour: %v", i+2, err) + } + if _, err := v.AskEnd(ControllerCaller, o.Call, "", "timed-out"); err != nil { + t.Fatal(err) + } + } + if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err == nil || + !strings.Contains(err.Error(), "3 times in the last hour") { + t.Fatalf("a fourth ask in the hour: %v", err) + } + asks, _ := v.Store.Asks(50) + outcomes := map[string]int{} + for _, a := range asks { + outcomes[a.Outcome]++ + } + if outcomes["dismissed"] != 1 || outcomes["timed-out"] != 2 { + t.Errorf("the record's outcomes: %v", outcomes) + } + // An ask left open counts as open for SecretAskOpenFor only: a prompt that died does not hold the secret. + w := newVault(t, nil) + clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) + w.Now = func() time.Time { return clock } + if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil { + t.Fatal(err) + } + clock = clock.Add(SecretAskOpenFor + time.Second) + if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil { + t.Errorf("an ask left open past its time still held the secret: %v", err) + } +} + +// Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the +// call's key, or a call that waited too long, keeps nothing. +func TestAnAskIsTakenFromTheControllerAloneAndOnlySealedToItsCall(t *testing.T) { + v := newVault(t, nil) + for _, caller := range []string{"g14.node-tools", "unproven", "", "person.jochen"} { + if _, err := v.AskOpen(caller, telegramAsk("x")); err == nil { + t.Errorf("%q opened an ask", caller) + } + } + if asks, _ := v.Store.Asks(10); len(asks) != 0 { + t.Fatalf("a refused caller filled the record: %+v", asks) + } + opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatal(err) + } + if _, err := v.AskEnd("g14.node-tools", opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil { + t.Error("another caller ended the ask") + } + other, _, _ := box.GenerateKey(rand.Reader) + if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(other[:]), "x"), ""); err == nil || + !strings.Contains(err.Error(), "not sealed to this call") { + t.Errorf("a value sealed to another key: %v", err) + } + if _, held, _ := v.Store.Read("own/anchor/telegram/telegram-token"); held { + t.Error("a value not sealed to the call was kept") + } + // The call's key is forgotten once used: the same call cannot be answered twice. + if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil { + t.Error("a call was answered twice") + } + asks, _ := v.Store.Asks(1) + if asks[0].Outcome != "failed" { + t.Errorf("the ask that failed ended %+v", asks[0]) + } + // A call that waited longer than its time is closed. + clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) + v.Now = func() time.Time { return clock } + late, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex") + if err != nil { + t.Fatal(err) + } + clock = clock.Add(CallOpenFor + time.Second) + if _, err := v.IssuedTake(ControllerCaller, late.Call, sealToCall(t, late.SealTo, "x")); err == nil { + t.Error("a call that waited too long was taken") + } + // An empty answer gives nothing and says so. + clock = clock.Add(time.Hour) + empty, _ := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if _, err := v.AskEnd(ControllerCaller, empty.Call, sealToCall(t, empty.SealTo, " \n"), ""); err == nil { + t.Error("an empty answer was kept") + } + if asks, _ := v.Store.Asks(1); asks[0].Outcome != "empty" { + t.Errorf("an empty answer ended %+v", asks[0]) + } +} + +// ---- the issuing provider's test, with a stand-in provider ------------------------------------------------- + +// standInProvider is a provider's tool for an `issued-by: software` offer, as plex's must answer (ADR 0305 §2 +// with the vault in the controller's place): it answers the vault's own account, which asks it itself (issue 723), +// and the controller, whose path for a provider filled the old way stays until R1 (issue 682); it refuses every +// other caller, and answers only the software's value sealed to the call's key. +func standInProvider(software *string) func(caller string, args map[string]any) (map[string]any, error) { + return func(caller string, args map[string]any) (map[string]any, error) { + if caller != ControllerCaller && !strings.HasSuffix(caller, ".mesh-vault") { + return nil, errors.New("this tool answers the vault and, until R1, the controller alone") + } + key, _ := args["seal_to"].(string) + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return nil, errors.New("seal_to is not a sealing key") + } + var to [32]byte + copy(to[:], raw) + sealed, err := box.SealAnonymous(nil, []byte(*software), &to, rand.Reader) + if err != nil { + return nil, err + } + return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil + } +} + +// issue is the controller's `secret issued` for one provider: one ask of the vault, which asks the provider's +// tool itself, in its own name. +func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) { + t.Helper() + v.Ask = (&aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){ + "plex.plex_issued_credential@anchor": provider}}).ask + got, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential") + if err != nil { + return Changed{}, err + } + rec, _, _ := v.Store.Read(got.Secret) + return Changed{Name: got.Secret, Generation: got.Generation, By: rec.ChangedBy, Unchanged: got.Unchanged}, nil +} + +func TestAnIssuingProviderAnswersOnlyTheVaultsCallSealedToTheVault(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}}) + software := "plex-token-from-the-vendor" + provider := standInProvider(&software) + + // The provider's tool refuses every caller but the vault's account and the controller, whatever it asks. + for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} { + if _, err := provider(caller, map[string]any{"provision": "plex-api", "seal_to": "x"}); err == nil { + t.Errorf("the provider's tool answered %q", caller) + } + } + // The vault takes the steps from the controller alone. + if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil { + t.Error("the vault opened an issued call for another caller") + } + + changed, err := issue(t, v, provider) + if err != nil { + t.Fatal(err) + } + if changed.Name != "provision/anchor/plex-api" || changed.Generation != 1 || changed.By != "plex on anchor" { + t.Errorf("kept as %+v", changed) + } + if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software { + t.Errorf("the vault holds %q", got) + } + rec, _, _ := v.Store.Read("provision/anchor/plex-api") + if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller { + t.Errorf("the record: origin %q via %q", rec.Origin, rec.ChangedVia) + } + + // Nothing rewritten for a value every holder has (ADR 0305 §5): no new generation, no write, nothing said. + path := filepath.Join(v.dir, "store", "secrets", "provision%2Fanchor%2Fplex-api.json") + before, _ := os.ReadFile(path) + said := len(v.bus.raw) + again, err := issue(t, v, provider) + if err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if !again.Unchanged || again.Generation != 1 || string(before) != string(after) || len(v.bus.raw) != said { + t.Errorf("the same value again rewrote something: %+v", again) + } + + // The software changes its value: the next ask keeps it as a new generation, and says so without it. + software = "plex-token-renewed" + renewed, err := issue(t, v, provider) + if err != nil || renewed.Generation != 2 { + t.Fatalf("%+v %v", renewed, err) + } + if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software { + t.Errorf("after the change the vault holds %q", got) + } + + // A value sealed to any other key is refused, and nothing changes. + opened, _ := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex") + stranger, _, _ := box.GenerateKey(rand.Reader) + if _, err := v.IssuedTake(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(stranger[:]), "forged")); err == nil { + t.Error("a value not sealed to the call was taken") + } + + // The software rotates it, not the mesh (ADR 0305 §9); and no other path puts it into the vault (§10). + if _, err := v.Rotate(ControllerCaller, "provision/anchor/plex-api", "person.jochen", "leaked"); err == nil || + !strings.Contains(err.Error(), "software") { + t.Errorf("rotate of an issued value: %v", err) + } + if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/plex-api", "person.jochen", ""); err == nil { + t.Error("an issued value was taken by accept") + } + if strings.Contains(everything(t, v), "plex-token") { + t.Fatal("the issued value is in something the vault said or wrote in the clear") + } +} + +// ---- accept and rotate ------------------------------------------------------------------------------------- + +// A value given at the controller's terminal is sealed there to the vault's call and opened here alone; an +// accepted provision's credential is never remade; a module's own given value rotates as a made one (ADR 0228). +func TestAnAcceptedValueOpensInTheVaultAloneAndIsNotRemade(t *testing.T) { + v := newVault(t, AllowList{"g14.node-provisioner": {"provision/novox/postgres", "own/g14/dunst/token"}}) + if _, err := v.AcceptOpen("g14.claude-code", "provision/novox/postgres", "", ""); err == nil { + t.Error("accept was opened for a caller that is not the controller") + } + for name, value := range map[string]string{"provision/novox/postgres": "pw-of-last-year", "own/g14/dunst/token": "tok"} { + opened, err := v.AcceptOpen(ControllerCaller, name, "jochen", "") + if err != nil { + t.Fatal(err) + } + changed, err := v.AcceptTake(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, value)) + if err != nil { + t.Fatal(err) + } + if changed.By != "jochen" { + t.Errorf("%s accepted by %q", name, changed.By) + } + if got := handedTo(t, v, "g14.node-provisioner", name); got != value { + t.Errorf("%s holds %q", name, got) + } + rec, _, _ := v.Store.Read(name) + if rec.Origin != OriginAccepted || rec.ChangedVia != ControllerCaller { + t.Errorf("%s: origin %q via %q", name, rec.Origin, rec.ChangedVia) + } + } + if _, err := v.Rotate(ControllerCaller, "provision/novox/postgres", "jochen", "leaked"); err == nil || + !strings.Contains(err.Error(), "not remade") { + t.Errorf("rotate of an accepted credential: %v", err) + } + rotated, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", "leaked") + if err != nil || rotated.Generation != 2 { + t.Fatalf("rotate of a module's own given value: %+v %v", rotated, err) + } + if got := handedTo(t, v, "g14.node-provisioner", "own/g14/dunst/token"); got == "tok" || len(got) != 40 { + t.Errorf("rotated to %q", got) + } + if strings.Contains(everything(t, v), "pw-of-last-year") { + t.Fatal("an accepted value is in something the vault said or wrote in the clear") + } +} + +// Rotate: refused for a secret not held and for an unproven caller; who asked is the proven caller, or the +// person the controller names when it carries the act — both kept. +func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", "leaked"); err == nil { + t.Error("a secret not held was rotated") + } + if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil { + t.Fatal(err) + } + if _, err := v.Rotate("unproven", "own/g14/dunst/token", "", "leaked"); err == nil { + t.Error("an unproven caller rotated") + } + if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", "leaked"); err != nil { + t.Fatal(err) + } + rec, _, _ := v.Store.Read("own/g14/dunst/token") + if rec.ChangedBy != "jochen" || rec.ChangedVia != ControllerCaller || rec.Generation != 2 { + t.Errorf("by %q via %q generation %d", rec.ChangedBy, rec.ChangedVia, rec.Generation) + } +} + +// Rotate is the controller's alone, and says why (the review of issue 675): only the controller knows whether a +// module is filled from the vault yet, so a rotation asked by any other caller — a proven one included — would +// drift from the value the controller holds; and a rotation without a reason is refused. Neither changes anything. +func TestRotateIsTheControllersAloneAndSaysWhy(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil { + t.Fatal(err) + } + for _, caller := range []string{"g14.claude-code", "g14.node-provisioner", "unproven", ""} { + if _, err := v.Rotate(caller, "own/g14/dunst/token", "jochen", "leaked"); err == nil || + !strings.Contains(err.Error(), "controller alone") { + t.Errorf("%q rotated: %v", caller, err) + } + } + for _, why := range []string{"", " "} { + if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", why); err == nil || + !strings.Contains(err.Error(), "why") { + t.Errorf("a rotation without a why (%q): %v", why, err) + } + } + if rec, _, _ := v.Store.Read("own/g14/dunst/token"); rec.Generation != 1 { + t.Fatalf("a refused rotation changed the secret: generation %d", rec.Generation) + } +} + +// The names the vault keeps them under are names, never paths. +func TestTheNamesOfWhatIsTakenIn(t *testing.T) { + if n, err := OwnSecret("g14", "dunst", "token"); err != nil || n != "own/g14/dunst/token" { + t.Errorf("%q %v", n, err) + } + if n, err := ProvisionSecret("novox", "postgres"); err != nil || n != "provision/novox/postgres" { + t.Errorf("%q %v", n, err) + } + for _, bad := range [][3]string{{"..", "x", "y"}, {"g14", "a/b", "y"}, {"g14", "x", ""}, {"G14", "x", "y"}} { + if _, err := OwnSecret(bad[0], bad[1], bad[2]); err == nil { + t.Errorf("%v was taken", bad) + } + } +} + +// ---- the verbs as the runtime calls them ------------------------------------------------------------------ + +// Each verb takes its caller from the runtime, never from its arguments, and names its secret one way only. +func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) { + v := newVault(t, nil) + env := &Env{Vault: v.Vault} + defs := definitions(env) + var asked []map[string]any + software := "tok" + v.Ask = (&aMesh{desk: aDesk("typed", &asked), tools: map[string]func(string, map[string]any) (map[string]any, error){ + "plex.plex_issued_credential@anchor": standInProvider(&software)}}).ask + ask := toolNamed(t, defs, Seat+".secret-ask") + args := map[string]any{"node": "anchor", "module": "telegram", "name": "telegram-token", "desk": "laptop", + "asked_by": "g14.claude-code", "caller": ControllerCaller} + if _, err := ask.Run(args); err == nil { + t.Error("an ask opened for a caller named in the arguments") + } + answer, err := ask.runAs(ControllerCaller, args) + if err != nil || answer.(Asked).Outcome != "given" || len(asked) != 1 { + t.Fatalf("secret-ask: %+v %v", answer, err) + } + listed, err := toolNamed(t, defs, "secret_asks").Run(map[string]any{}) + if err != nil || len(listed.([]AskRecord)) != 2 { + t.Fatalf("secret_asks: %v %v", listed, err) + } + + accept := toolNamed(t, defs, Seat+".accept") + if _, err := accept.runAs(ControllerCaller, map[string]any{"node": "g14", "provision": "postgres"}); err == nil { + t.Error("accept took a name both ways") + } + answer, err = accept.runAs(ControllerCaller, map[string]any{"provider": "novox", "provision": "postgres", "by": "jochen"}) + if err != nil { + t.Fatal(err) + } + o := answer.(Opened) + if _, err := accept.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "pw")}); err != nil { + t.Fatal(err) + } + rotate := toolNamed(t, defs, Seat+".rotate") + if _, err := rotate.runAs("g14.claude-code", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil { + t.Error("a secret not held was rotated through the verb") + } + + issued := toolNamed(t, defs, Seat+".issued") + answer, err = issued.runAs(ControllerCaller, map[string]any{"provision": "plex-api", "provider": "anchor", + "module": "plex", "tool": "plex_issued_credential"}) + if err != nil || answer.(Issued).Secret != "provision/anchor/plex-api" { + t.Fatalf("issued: %+v %v", answer, err) + } + broken := &Env{Broken: errors.New("no store")} + if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil || + err.Error() != "no store" { + t.Errorf("a broken vault said %v", err) + } +} + +// How an ask ended is written under the record's lock (the review of issue 675), as its opening is: two writers +// never roll asks.jsonl at once, and an ask opened meanwhile never counts its bounds from a half-written record. +// With the lock held elsewhere, AskEnd waits, and writes nothing until it is let go. +func TestTheEndOfAnAskIsWrittenUnderTheRecordsLock(t *testing.T) { + v := newVault(t, nil) + opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatal(err) + } + ended := func() int { + t.Helper() + raw, err := os.ReadFile(filepath.Join(v.Store.dir, "asks.jsonl")) + if err != nil { + t.Fatal(err) + } + return strings.Count(string(raw), `"`+string(AskEnded)+`"`) + } + v.Store.asksMu.Lock() + done := make(chan error, 1) + go func() { + _, err := v.AskEnd(ControllerCaller, opened.Call, "", "dismissed") + done <- err + }() + select { + case err := <-done: + v.Store.asksMu.Unlock() + t.Fatalf("the end of the ask was written while another writer held the record (%v)", err) + case <-time.After(200 * time.Millisecond): + } + if n := ended(); n != 0 { + v.Store.asksMu.Unlock() + t.Fatalf("%d end(s) written under another writer's lock", n) + } + v.Store.asksMu.Unlock() + if err := <-done; err != nil { + t.Fatal(err) + } + if n := ended(); n != 1 { + t.Fatalf("%d end(s) written once the lock was let go", n) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go new file mode 100644 index 00000000..01e99e8f --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "strings" + "testing" +) + +// A bus account the controller minted for a core or moved module is handed to the vault with origin issued (novox/hq +// ADR 0374, issue 787): kept as that module's own secret on that machine, served to the controller's core get and to +// the moved module's client, replaced in place on a re-issue with `secret-changed`, and never made anew by the vault. + +func handIssued(t *testing.T, v testVault, caller, secret, value string) (Changed, error) { + t.Helper() + opened, err := v.AcceptOpen(caller, secret, "module issue", OriginIssued) + if err != nil { + return Changed{}, err + } + return v.AcceptTake(caller, opened.Call, sealToCall(t, opened.SealTo, value)) +} + +func TestAHandedBusAccountIsKeptServedReplacedAndNeverMadeByTheVault(t *testing.T) { + const moved = "own/g14/moved-bot/broker" + v := newVault(t, AllowList{"provisioner.g14": {moved}}) + + first, err := handIssued(t, v, ControllerCaller, moved, `{"password":"first-pw"}`) + if err != nil || first.Generation != 1 { + t.Fatalf("the handed account: %+v %v", first, err) + } + rec, held, _ := v.Store.Read(moved) + if !held || rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller { + t.Fatalf("kept as origin %q via %q (held %v), want issued via the controller", rec.Origin, rec.ChangedVia, held) + } + if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"first-pw"}` { + t.Errorf("the moved module's client is handed %q", got) + } + + // A re-issue replaces it in place, a new generation, and the vault says it changed. + before := len(v.bus.events) + second, err := handIssued(t, v, ControllerCaller, moved, `{"password":"second-pw"}`) + if err != nil || second.Generation != 2 || second.Unchanged { + t.Fatalf("the re-issue: %+v %v", second, err) + } + if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"second-pw"}` { + t.Errorf("after the re-issue the client is handed %q: a stale value stayed", got) + } + changedEvents := 0 + for _, e := range v.bus.events[before:] { + if e["event"] == ChangedEvent { + changedEvents++ + } + } + if changedEvents != 1 { + t.Errorf("the re-issue raised %d secret-changed events, want 1", changedEvents) + } + + // The vault makes no value for it: not a rotation, and not an accept without origin over it either way round. + if _, err := v.Rotate(ControllerCaller, moved, "jochen", "leaked"); err == nil || !strings.Contains(err.Error(), "ADR 0374") { + t.Errorf("the vault rotated a covered account: %v", err) + } + if _, err := v.Rotate(ControllerCaller, "own/g14/moved-bot/provisioner-broker", "jochen", "leaked"); err == nil { + t.Error("the vault made a first value for a covered account by rotating it") + } + if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"second-pw"}` { + t.Errorf("a refused rotation changed the value to %q", got) + } + + // A secret that is not a covered account is not handed with origin issued; and only the controller hands one. + if _, err := v.AcceptOpen(ControllerCaller, "own/g14/moved-bot/token", "x", OriginIssued); err == nil { + t.Error("origin issued was taken for a secret that is not a bus account") + } + if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/postgres", "x", OriginIssued); err == nil { + t.Error("origin issued was taken for a provision's credential") + } + if _, err := v.AcceptOpen("g14.moved-bot", moved, "x", OriginIssued); err == nil { + t.Error("a caller that is not the controller handed a covered account") + } + if _, err := v.AcceptOpen(ControllerCaller, moved, "x", "made"); err == nil { + t.Error("an origin accept does not take was taken") + } + if strings.Contains(everything(t, v), "first-pw") || strings.Contains(everything(t, v), "second-pw") { + t.Fatal("a handed account is in something the vault said or wrote in the clear") + } +} + +// A core module's handed account is the one the controller's core get is answered, sealed to its machine's key. +func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) { + const natsBroker = "own/anchor/nats/broker" + v := newVault(t, nil) + if _, err := handIssued(t, v, ControllerCaller, natsBroker, `{"password":"core-pw"}`); err != nil { + t.Fatal(err) + } + rec, held, _ := v.Store.Read(natsBroker) + if !held || rec.Origin != OriginIssued { + t.Fatalf("the core's account is held %v as %q", held, rec.Origin) + } + if !Covered(natsBroker) || Covered("own/anchor/nats/token") || Covered("pair/a/b/c/broker") { + t.Error("the covered names are not the module's broker and provisioner-broker own secrets") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 25f5cd4d..d8f09abd 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -8,6 +8,7 @@ package main import ( + "errors" "fmt" "os" "time" @@ -45,17 +46,24 @@ func build() *Env { say("%v", env.Broken) return env } - // Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go). - env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say} + // Who may ask (askers.go): a node's provisioner client, for what its module's plan there names. + // Where it sees a consumer bind a provider (pair.go): the same mirror, every key. + env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store}, + Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, + Now: time.Now, Log: say} return env } // adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what // `operator key set` recorded; the controller keeps only a public half there), and seals again any -// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. -func adoptOperatorKey(env *Env) { +// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It +// returns why the pass did not re-seal everything, or nil. +func adoptOperatorKey(env *Env) error { if env.Vault == nil { - return + if env.Broken != nil { + return env.Broken + } + return errors.New("the vault was not built") } if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" { key, err := publicPEM(raw) @@ -67,20 +75,36 @@ func adoptOperatorKey(env *Env) { env.refuseKey(err.Error()) say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err) } - return + return err } - if _, err := env.Vault.Reseal(); err != nil { + _, err := env.Vault.Reseal() + if err != nil { say("%v", err) } + return err } +// readAssignment is one value of the controller's assignments mirror, read through the runtime under the vault's +// own account (its manifest `reads` it), or nil when there is none. +func readAssignment(key string) ([]byte, error) { + e, err := stdio.State(AssignmentsRead).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} + +// assignmentKeys is every key of the controller's assignments mirror, read the same way. +func assignmentKeys() ([]string, error) { return stdio.State(AssignmentsRead).Keys() } + func main() { env := build() go func() { tick := time.NewTicker(time.Minute) defer tick.Stop() for { - adoptOperatorKey(env) + env.pass() + env.pairPass() if env.Custody != nil { env.Custody.Pass() } diff --git a/modules/mesh-vault/cmd/mesh-vault/move.go b/modules/mesh-vault/cmd/mesh-vault/move.go new file mode 100644 index 00000000..8db5f853 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/move.go @@ -0,0 +1,369 @@ +package main + +// The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676; +// the operator's decision of 2026-10-14 on that issue). +// +// **Only a machine the value was sealed to can open it.** The controller's store holds every value sealed to a +// machine's sealing key (and, for some, to the operator key); the controller cannot open one. So each machine's +// node-engine opens what was sealed to it and hands it in, in the two steps of the intake (intake.go): `move-in` +// names the secret, where it came from and its generation in the controller's store, and is answered a call and +// a key made for it; the second step carries the value sealed to that key and a key of the node-engine's own, +// and is answered what the vault kept, read back from its own store and sealed to that key — the node-engine +// opens it and compares it with what it handed in, which is the move's proof. +// +// **From the node-engine of a machine the value was sealed to, and no other caller.** The caller is the bus user +// the runtime proved, `node.` (the controller's broker names a node-engine so): a module's own secret +// was sealed to its one machine, a pair credential to both its ends. +// +// **Copied as it is, and never over a value the vault holds.** Nothing is rotated (ADR 0349's rule for a move): +// the value keeps its origin, the controller's generation and when it was made. The same value again changes +// nothing — the other end of a pair, or the move run again — and a different one is refused, so a value the +// vault holds, rotated or given since, is never put back to the controller's older one. +// +// The controller's record of asks at a desk holds no value, and the controller copies it as it is (`move-asks`). +// +// Which secret each row of the controller's store is, by name in the vault's store: +// +// own/// a row of module_secret +// pair////[/] a row of secret, its local name last when it has one + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/nacl/box" +) + +// NodeEnginePrefix starts the bus user of a machine's node-engine: `node.`. +const NodeEnginePrefix = "node." + +// MovedFrom is where a value the move took came from: the node-engine that handed it in, and the generation the +// controller's store stamped it with (ADR 0158, ADR 0305 §5), kept as it was. Never a value. +type MovedFrom struct { + By string `json:"by"` + At time.Time `json:"at"` + Generation string `json:"generation,omitempty"` +} + +// MoveOf is what one row of the controller's store is: a module's own secret (node, module, name) or a pair +// credential (provider, provision, consumer, consumer module, local name), where it came from, its generation +// and when it was made. +type MoveOf struct { + Node, Module, Name string + Provider, Provision, Consumer, ConsumerModule, Local string + Origin, Generation string + MadeAt time.Time + // SealingKey is the caller's machine's sealing key as its node-engine states it (a raw X25519 public key in + // base64), kept as that machine's: what the core's credentials are sealed to (core.go, issue 763). Optional. + SealingKey string +} + +// MovedIn is what the vault kept, and that value read back from its store sealed to the node-engine's key. +type MovedIn struct { + Name string `json:"name"` + Generation int `json:"generation"` + Unchanged bool `json:"unchanged,omitempty"` + // ReadBack is the value the vault's store holds now, sealed to the key the second step sent (NaCl anonymous + // box, base64). + ReadBack string `json:"read_back"` +} + +// PairSecret is the vault's name for one row of the controller's pair credentials. +func PairSecret(provider, provision, consumer, consumerModule, local string) (string, error) { + parts := map[string]string{"the provider's machine": provider, "the provision": provision, + "the consumer's machine": consumer, "the consuming module": consumerModule} + if local != "" { + parts["the local name"] = local + } + for what, v := range parts { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + name := "pair/" + provider + "/" + provision + "/" + consumer + "/" + consumerModule + if local != "" { + name += "/" + local + } + return name, nil +} + +// secret is the row's name in the vault and the machines it was sealed to. +func (m MoveOf) secret() (string, []string, error) { + own := m.Node != "" || m.Module != "" || m.Name != "" + pair := m.Provider != "" || m.Provision != "" || m.Consumer != "" || m.ConsumerModule != "" || m.Local != "" + switch { + case own && !pair: + name, err := OwnSecret(m.Node, m.Module, m.Name) + return name, []string{m.Node}, err + case pair && !own: + name, err := PairSecret(m.Provider, m.Provision, m.Consumer, m.ConsumerModule, m.Local) + return name, []string{m.Provider, m.Consumer}, err + } + return "", nil, errors.New("name a module's own secret (node, module, name) or a pair credential (provider, " + + "provision, consumer, consumer_module and local when it has one), one of the two") +} + +// movedOrigins are the controller's words for where a value came from, as the vault's. +var movedOrigins = map[string]string{"made": OriginMade, "accepted": OriginAccepted, "issued": OriginIssued} + +// nodeEngineOf is the machine whose node-engine caller is, if it is one. +func nodeEngineOf(caller string) (string, bool) { + node, ok := strings.CutPrefix(caller, NodeEnginePrefix) + return node, ok && namePart.MatchString(node) && !strings.Contains(node, ".") +} + +// MoveOpen opens a call to take one value of the controller's store from the node-engine of a machine it was +// sealed to. +func (v *Vault) MoveOpen(caller string, m MoveOf) (Opened, error) { + node, ok := nodeEngineOf(caller) + if !ok { + if caller == "" { + caller = "an unproven caller" + } + return Opened{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone, which opens what was "+ + "sealed to its machine; %s is not a node-engine", caller) + } + secret, sealedTo, err := m.secret() + if err != nil { + return Opened{}, err + } + if !slices.Contains(sealedTo, node) { + return Opened{}, fmt.Errorf("%s was sealed to %s, not to %s: only a machine it was sealed to moves it in", + secret, strings.Join(sealedTo, " and "), node) + } + if movedOrigins[m.Origin] == "" { + return Opened{}, fmt.Errorf("the origin %q is not one the controller's store holds (made, accepted or "+ + "issued); nothing is taken", m.Origin) + } + if len(m.Generation) > 120 || strings.ContainsAny(m.Generation, "\r\n\x00") { + return Opened{}, errors.New("the generation is not one short line; nothing is taken") + } + if m.SealingKey != "" { + stated, err := sealingKey(m.SealingKey) + if err != nil { + return Opened{}, err + } + if err := v.recordNodeKey(node, stated, caller); err != nil { + return Opened{}, err + } + } + id, key, err := v.openCall("move-in", secret, caller, nil) + if err != nil { + return Opened{}, err + } + v.callsMu.Lock() + if c := v.pending[id]; c != nil { + moved := m + c.move = &moved + } + v.callsMu.Unlock() + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// readBackKey is the key a read-back is sealed to: a raw X25519 public key in base64, the box the node-engine's +// own sealing key opens. A PEM, which a private key could be, is refused. +func readBackKey(key string) (*[32]byte, error) { + if privateKeyIn(key) || strings.Contains(key, "-----") { + return nil, errors.New("the key to read back to is a raw X25519 public key in base64, never a PEM and never " + + "a private key; nothing was taken") + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key)) + if err != nil || len(raw) != 32 { + return nil, errors.New("the second step names no key to read back to (`read_back_to`, a raw X25519 public " + + "key in base64); nothing was taken") + } + var pub [32]byte + copy(pub[:], raw) + return &pub, nil +} + +// MoveTake opens the value sealed to the call's key and keeps it as it is, unless the vault holds another; then +// answers what the store holds, read back and sealed to readBackTo. +func (v *Vault) MoveTake(caller, call, sealed, readBackTo string) (MovedIn, error) { + if _, ok := nodeEngineOf(caller); !ok { + return MovedIn{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone; %q is not one", caller) + } + to, err := readBackKey(readBackTo) + if err != nil { + return MovedIn{}, err + } + c, err := v.takeCall("move-in", call) + if err != nil { + return MovedIn{}, err + } + if c.by != caller || c.move == nil { + return MovedIn{}, fmt.Errorf("the move-in call %q was opened by another caller; nothing was taken", call) + } + value, err := c.open(sealed) + if err != nil { + return MovedIn{}, err + } + defer zero(value) + if len(value) == 0 { + return MovedIn{}, errors.New("the value handed in is empty; nothing was taken") + } + m := c.move + changed, err := v.keepWith(c.secret, string(value), caller, "", movedOrigins[m.Origin], keepOptions{ + neverReplace: true, madeAt: m.MadeAt, movedFrom: &MovedFrom{By: caller, Generation: m.Generation}}) + if err != nil { + return MovedIn{}, err + } + rec, held, err := v.Store.Read(c.secret) + if err != nil || !held { + return MovedIn{}, fmt.Errorf("%s was kept but cannot be read back from the vault's store: %v", c.secret, err) + } + back, err := seal.Open(rec.AtRest, v.Own.PrivateKey) + if err != nil { + return MovedIn{}, fmt.Errorf("%s was kept but does not open with the vault's key: %w", c.secret, err) + } + boxed, err := box.SealAnonymous(nil, []byte(back), to, rand.Reader) + back = "" + if err != nil { + return MovedIn{}, err + } + v.log("moved in %s (generation %d) from %s, origin %s, the controller's generation %q", c.secret, + changed.Generation, caller, movedOrigins[m.Origin], m.Generation) + return MovedIn{Name: c.secret, Generation: rec.Generation, Unchanged: changed.Unchanged, + ReadBack: base64.StdEncoding.EncodeToString(boxed)}, nil +} + +// ---- move-asks ---------------------------------------------------------------------------------------------- + +// MovedAsk is one row of the controller's record of asks at a desk (its secret_ask table): no value. +type MovedAsk struct { + ID int64 `json:"id"` + Node string `json:"node"` + Module string `json:"module"` + Name string `json:"name"` + Desk string `json:"desk"` + AskedBy string `json:"asked_by"` + OpenedAt time.Time `json:"opened_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Outcome string `json:"outcome,omitempty"` +} + +// AsksMoved is what a copy of the record of asks did. +type AsksMoved struct { + Copied int `json:"copied"` + Already int `json:"already"` +} + +// MovedAsksAtOnce bounds one call: the controller sends its record in parts. +const MovedAsksAtOnce = 500 + +// movedAskID is the vault's id of a row of the controller's record. +func movedAskID(id int64) string { return "controller-" + strconv.FormatInt(id, 10) } + +// MoveAsks copies rows of the controller's record of asks into the vault's, as they are: each ask's opening and, +// when it ended, its end — once, so a copy run again adds nothing. From the controller alone, whose record it is. +// Every row is judged before any is written. +func (v *Vault) MoveAsks(caller string, asks []MovedAsk) (AsksMoved, error) { + if err := fromTheController(caller, "move-asks"); err != nil { + return AsksMoved{}, err + } + if len(asks) > MovedAsksAtOnce { + return AsksMoved{}, fmt.Errorf("%d asks at once is more than %d; send them in parts", len(asks), MovedAsksAtOnce) + } + type lines struct{ opened, ended AskRecord } + var all []lines + for _, a := range asks { + secret, err := OwnSecret(a.Node, a.Module, a.Name) + if err != nil { + return AsksMoved{}, fmt.Errorf("ask %d: %w; nothing was copied", a.ID, err) + } + if !namePart.MatchString(a.Desk) || a.OpenedAt.IsZero() || a.ID <= 0 { + return AsksMoved{}, fmt.Errorf("ask %d names no desk, no time it opened or no id; nothing was copied", a.ID) + } + l := lines{opened: AskRecord{ID: movedAskID(a.ID), At: a.OpenedAt.UTC(), Event: AskOpened, Secret: secret, + Node: a.Node, Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedByName(a.AskedBy), + Caller: ControllerCaller}} + if a.EndedAt != nil { + if a.Outcome != "given" && !askOutcomes[a.Outcome] { + return AsksMoved{}, fmt.Errorf("ask %d ended %q, which is no outcome the record knows; nothing was "+ + "copied", a.ID, a.Outcome) + } + l.ended = AskRecord{ID: movedAskID(a.ID), At: a.EndedAt.UTC(), Event: AskEnded, Secret: secret, + Outcome: a.Outcome} + } + all = append(all, l) + } + v.Store.asksMu.Lock() + defer v.Store.asksMu.Unlock() + held, err := v.Store.asks() + if err != nil { + return AsksMoved{}, fmt.Errorf("the record of asks cannot be read: %w; nothing was copied", err) + } + seen := map[string]bool{} + for _, r := range held { + if r.Event == AskOpened { + seen[r.ID] = true + } + } + out := AsksMoved{} + for _, l := range all { + if seen[l.opened.ID] { + out.Already++ + continue + } + if err := v.recordAsk(l.opened); err != nil { + return out, fmt.Errorf("ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err) + } + if l.ended.ID != "" { + if err := v.recordAsk(l.ended); err != nil { + return out, fmt.Errorf("the end of ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err) + } + } + seen[l.opened.ID] = true + out.Copied++ + } + return out, nil +} + +// StateNodeKey records the sealing key the node-engine of caller's machine states in its own name (novox/hq ADR 0372, +// issue 786), at its start and whenever the key changes, whether or not it ever moved a secret in. The machine is the +// caller's, as the bus proved it (`node.`), never an argument. A first key is handled like a changed one: the +// machine's core secrets, sealed to no key until now or to the old one, are said changed so the controller asks again +// (ADR 0349 §16). The same key again changes nothing and says nothing. +func (v *Vault) StateNodeKey(caller, key string) error { + node, ok := nodeEngineOf(caller) + if !ok { + if caller == "" || caller == stdio.Unproven { + caller = "an unproven caller" + } + return fmt.Errorf("node-key takes a machine's sealing key from that machine's node-engine alone, in its own "+ + "name; %s is not a node-engine", caller) + } + stated, err := sealingKey(key) + if err != nil { + return err + } + held, had, err := v.Store.NodeKey(node) + if err != nil { + return err + } + if had && held == stated { + return nil + } + if err := v.recordNodeKey(node, stated, caller); err != nil { + return err + } + all, err := v.Store.List() + if err != nil { + return fmt.Errorf("%s's key is recorded, but its core secrets could not be listed to say them changed: %w", node, err) + } + for _, r := range all { + parts := strings.Split(r.Name, "/") + if len(parts) == 4 && parts[0] == "own" && parts[1] == node && slices.Contains(CoreModules, parts[2]) { + v.announce(Changed{Name: r.Name, Generation: r.Generation, At: v.Now().UTC(), By: caller}) + } + } + return nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/move_test.go b/modules/mesh-vault/cmd/mesh-vault/move_test.go new file mode 100644 index 00000000..d2e41c1b --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/move_test.go @@ -0,0 +1,321 @@ +package main + +// The move of the controller's secrets into the vault (design 57's V3, issue 676, the operator's decision of +// 2026-10-14): each machine's node-engine opens what was sealed to it and hands it in, sealed to a key the vault +// made for the call, keeping where it came from and its generation; the vault takes that step only from the +// node-engine of a machine the value was sealed to, never replaces a value it holds, and answers what it kept +// read back from its own store, sealed to a key the node-engine sent. The controller copies its record of asks +// as it is. Nothing is rotated. + +import ( + "crypto/rand" + "encoding/base64" + "strings" + "testing" + "time" + + "golang.org/x/crypto/nacl/box" +) + +// aReadBackKey is a key the node-engine makes for one read-back: its public half to send, its private to open. +func aReadBackKey(t *testing.T) (string, *[32]byte, *[32]byte) { + t.Helper() + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(pub[:]), pub, priv +} + +// openReadBack opens what the vault read back, as the node-engine does. +func openReadBack(t *testing.T, sealed string, pub, priv *[32]byte) string { + t.Helper() + blob, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + t.Fatal(err) + } + out, ok := box.OpenAnonymous(nil, blob, pub, priv) + if !ok { + t.Fatal("the read-back does not open with the key sent for it") + } + return string(out) +} + +// moveIn is one node-engine's two steps for one value. +func moveIn(t *testing.T, v testVault, caller string, m MoveOf, value string) (MovedIn, error) { + t.Helper() + opened, err := v.MoveOpen(caller, m) + if err != nil { + return MovedIn{}, err + } + key, _, _ := aReadBackKey(t) + return v.MoveTake(caller, opened.Call, sealToCall(t, opened.SealTo, value), key) +} + +const movedValue = "Xq3-the-superusers-password-made-in-2025" + +func superuser() MoveOf { + return MoveOf{Node: "anchor", Module: "postgres", Name: "superuser-password", Origin: "made", + Generation: "g-2025-11-02T10:00:00Z-1", MadeAt: time.Date(2025, 11, 2, 10, 0, 0, 0, time.UTC)} +} + +// A machine's own value moves as it is: opened in the vault alone, kept with its origin, the controller's +// generation and when it was made, and read back from the vault's store to the node-engine, sealed to its key. +func TestANodeEngineMovesInAValueSealedToItsMachineAsItIs(t *testing.T) { + v := newVault(t, nil) + opened, err := v.MoveOpen("node.anchor", superuser()) + if err != nil { + t.Fatal(err) + } + if opened.Secret != "own/anchor/postgres/superuser-password" || opened.SealTo == "" || opened.Call == "" { + t.Fatalf("opened %+v", opened) + } + key, pub, priv := aReadBackKey(t) + moved, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key) + if err != nil { + t.Fatal(err) + } + if moved.Name != opened.Secret || moved.Generation != 1 || moved.Unchanged { + t.Errorf("moved %+v", moved) + } + if got := openReadBack(t, moved.ReadBack, pub, priv); got != movedValue { + t.Errorf("the read-back opens to %q", got) + } + rec, held, _ := v.Store.Read(opened.Secret) + if !held || rec.Origin != OriginMade || rec.ChangedBy != "node.anchor" || rec.MovedFrom == nil || + rec.MovedFrom.Generation != "g-2025-11-02T10:00:00Z-1" || rec.MovedFrom.By != "node.anchor" || + !rec.MadeAt.Equal(superuser().MadeAt) { + t.Errorf("the record: %+v moved from %+v", rec, rec.MovedFrom) + } + // Kept as every value is: a copy for the operator. + if got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed); got != movedValue { + t.Errorf("the operator copy opens to %q", got) + } + if strings.Contains(everything(t, v, opened, moved), movedValue) { + t.Fatal("the value is in something the vault said, answered or wrote in the clear") + } +} + +// Each origin the controller's store holds is kept as the vault's word for it; any other is refused. +func TestAMovedValueKeepsWhereItCameFrom(t *testing.T) { + v := newVault(t, nil) + for origin, want := range map[string]string{"made": OriginMade, "accepted": OriginAccepted, "issued": OriginIssued} { + m := superuser() + m.Name, m.Origin = "from-"+origin, origin + if _, err := moveIn(t, v, "node.anchor", m, "value-"+origin); err != nil { + t.Fatalf("%s: %v", origin, err) + } + rec, _, _ := v.Store.Read("own/anchor/postgres/from-" + origin) + if rec.Origin != want { + t.Errorf("%s was kept as %q", origin, rec.Origin) + } + } + m := superuser() + m.Origin = "invented" + if _, err := v.MoveOpen("node.anchor", m); err == nil || !strings.Contains(err.Error(), "origin") { + t.Errorf("an unknown origin: %v", err) + } +} + +// Only the node-engine of a machine the value was sealed to takes the step: not the controller, not a module's +// tools, not another machine's node-engine. +func TestOnlyTheNodeEngineOfAMachineTheValueWasSealedToMovesIt(t *testing.T) { + v := newVault(t, nil) + for _, caller := range []string{ControllerCaller, "anchor.node-tools", "anchor.mesh-vault", "node.shanks", "", + "node.", "node.anchor.x"} { + if _, err := v.MoveOpen(caller, superuser()); err == nil { + t.Errorf("%q opened a move of anchor's value", caller) + } + } + // A pair credential was sealed to both its ends: either may move it, and nobody else. + pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea", + Origin: "made", Generation: "g-1"} + for _, caller := range []string{"node.anchor", "node.shanks"} { + if _, err := v.MoveOpen(caller, pair); err != nil { + t.Errorf("%s, an end of the pair, was refused: %v", caller, err) + } + } + if _, err := v.MoveOpen("node.g14", pair); err == nil { + t.Error("a machine at neither end opened a move of the pair") + } + // The second step is the first step's caller's alone. + opened, err := v.MoveOpen("node.anchor", superuser()) + if err != nil { + t.Fatal(err) + } + key, _, _ := aReadBackKey(t) + if _, err := v.MoveTake("node.shanks", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err == nil { + t.Error("another machine's node-engine took the second step") + } + if _, held, _ := v.Store.Read("own/anchor/postgres/superuser-password"); held { + t.Error("a value was kept from the wrong caller") + } +} + +// A pair credential is named by both ends and its local name, so every row of the controller's store is one +// secret in the vault, kept as it is. +func TestAPairCredentialMovesUnderTheNameOfItsRow(t *testing.T) { + v := newVault(t, nil) + pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea", + Local: "replica", Origin: "accepted", Generation: "g-7"} + moved, err := moveIn(t, v, "node.shanks", pair, "the-pair-value") + if err != nil { + t.Fatal(err) + } + if moved.Name != "pair/anchor/database/shanks/gitea/replica" { + t.Errorf("named %s", moved.Name) + } + pair.Local = "" + if moved, err = moveIn(t, v, "node.anchor", pair, "the-ordinary-value"); err != nil || + moved.Name != "pair/anchor/database/shanks/gitea" { + t.Errorf("the ordinary one: %+v, %v", moved, err) + } +} + +// The move never replaces a value: the same value again changes nothing (the other end of a pair, or a move run +// twice), and a different one is refused, so a value the vault holds is never put back to an older one. +func TestTheMoveNeverReplacesAValueTheVaultHolds(t *testing.T) { + v := newVault(t, nil) + pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea", + Origin: "made", Generation: "g-1"} + if _, err := moveIn(t, v, "node.anchor", pair, "the-pair-value"); err != nil { + t.Fatal(err) + } + opened, err := v.MoveOpen("node.shanks", pair) + if err != nil { + t.Fatal(err) + } + key, pub, priv := aReadBackKey(t) + again, err := v.MoveTake("node.shanks", opened.Call, sealToCall(t, opened.SealTo, "the-pair-value"), key) + if err != nil || !again.Unchanged || again.Generation != 1 { + t.Fatalf("the other end's same value: %+v, %v", again, err) + } + if got := openReadBack(t, again.ReadBack, pub, priv); got != "the-pair-value" { + t.Errorf("read back %q", got) + } + if _, err := v.Rotate(ControllerCaller, "pair/anchor/database/shanks/gitea", "jochen", "a test"); err != nil { + t.Fatal(err) + } + if _, err := moveIn(t, v, "node.anchor", pair, "the-pair-value"); err == nil || + !strings.Contains(err.Error(), "never replaces") { + t.Fatalf("a move over a value the vault changed since: %v", err) + } + rec, _, _ := v.Store.Read("pair/anchor/database/shanks/gitea") + if rec.Generation != 2 || rec.Origin != OriginMade { + t.Errorf("the vault's own value was touched: %+v", rec) + } +} + +// A second step that is not sealed to its call, or carries no key to read back to, takes nothing. +func TestAMoveTakesNothingItCannotOpenOrReadBack(t *testing.T) { + v := newVault(t, nil) + opened, err := v.MoveOpen("node.anchor", superuser()) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"", "not a key", "-----BEGIN PRIVATE KEY-----"} { + if _, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err == nil { + t.Errorf("read-back key %q was taken", key) + } + } + // A refused key leaves the call open for the right one. + other, _ := v.MoveOpen("node.anchor", superuser()) + key, _, _ := aReadBackKey(t) + if _, err := v.MoveTake("node.anchor", other.Call, "bm90IHNlYWxlZA==", key); err == nil { + t.Error("a value not sealed to the call was taken") + } + if _, held, _ := v.Store.Read("own/anchor/postgres/superuser-password"); held { + t.Error("something was kept") + } + if moved, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err != nil || + moved.Generation != 1 { + t.Errorf("the call after a refused key: %+v, %v", moved, err) + } +} + +// The controller's record of asks is copied as it is, once: each ask's opening and its end, who asked and at which +// desk, and the bounds then count them. From the controller alone. +func TestTheRecordOfAsksIsCopiedAsItIsOnce(t *testing.T) { + v := newVault(t, nil) + now := v.Now() + ended := now.Add(-30 * time.Second) + asks := []MovedAsk{ + {ID: 41, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "g14/claude-code", + OpenedAt: now.Add(-50 * time.Minute), EndedAt: &ended, Outcome: "given"}, + {ID: 42, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "g14/claude-code", + OpenedAt: now.Add(-40 * time.Minute), EndedAt: &ended, Outcome: "dismissed"}, + {ID: 43, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "ace/agent", + OpenedAt: now.Add(-20 * time.Minute)}, + } + if _, err := v.MoveAsks("node.anchor", asks); err == nil { + t.Error("a node-engine copied the record of asks") + } + copied, err := v.MoveAsks(ControllerCaller, asks) + if err != nil || copied.Copied != 3 || copied.Already != 0 { + t.Fatalf("copied %+v, %v", copied, err) + } + again, err := v.MoveAsks(ControllerCaller, asks) + if err != nil || again.Copied != 0 || again.Already != 3 { + t.Fatalf("copied again %+v, %v", again, err) + } + all, _ := v.Store.Asks(100) + if len(all) != 5 { + t.Fatalf("%d lines: %+v", len(all), all) + } + opened := map[string]AskRecord{} + for _, r := range all { + if r.Event == AskOpened { + opened[r.ID] = r + } + } + if r := opened["controller-41"]; r.Secret != "own/anchor/telegram/telegram-token" || r.AskedBy != "g14/claude-code" || + r.Desk != "laptop" || !r.At.Equal(asks[0].OpenedAt) || r.Caller != ControllerCaller { + t.Errorf("ask 41: %+v", r) + } + // Three asks in the hour, from the controller's record: the vault's bound holds them. + if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code")); err == nil || + !strings.Contains(err.Error(), "in the last hour") { + t.Errorf("an ask after three copied in the hour: %v", err) + } + bad := []MovedAsk{{ID: 44, Node: "anchor", Module: "telegram", Name: "x", Desk: "laptop", AskedBy: "a", + OpenedAt: now, EndedAt: &ended, Outcome: "whatever"}} + if _, err := v.MoveAsks(ControllerCaller, bad); err == nil { + t.Error("an ask that ended in no outcome the record knows was copied") + } +} + +// The two steps as the runtime calls the tool: the caller is the one it proved, never an argument. +func TestMoveInThroughTheTool(t *testing.T) { + v := newVault(t, nil) + tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-in") + first := map[string]any{"node": "anchor", "module": "postgres", "name": "superuser-password", "origin": "made", + "generation": "g-1", "made_at": "2025-11-02T10:00:00Z"} + if _, err := tool.runAs("controller", first); err == nil { + t.Fatal("the controller opened a move") + } + got, err := tool.runAs("node.anchor", first) + if err != nil { + t.Fatal(err) + } + opened := got.(Opened) + key, pub, priv := aReadBackKey(t) + got, err = tool.runAs("node.anchor", map[string]any{"call": opened.Call, + "sealed": sealToCall(t, opened.SealTo, movedValue), "read_back_to": key}) + if err != nil { + t.Fatal(err) + } + if back := openReadBack(t, got.(MovedIn).ReadBack, pub, priv); back != movedValue { + t.Errorf("read back %q", back) + } + rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password") + if rec.MadeAt.Year() != 2025 || rec.MovedFrom == nil || rec.MovedFrom.Generation != "g-1" { + t.Errorf("kept %+v", rec) + } + asks := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-asks") + moved, err := asks.runAs("controller", map[string]any{"asks": []any{map[string]any{"id": 7, "node": "anchor", + "module": "telegram", "name": "telegram-token", "desk": "laptop", "asked_by": "g14/claude-code", + "opened_at": "2026-10-01T10:00:00Z", "ended_at": "2026-10-01T10:00:20Z", "outcome": "given"}}}) + if err != nil || moved.(AsksMoved).Copied != 1 { + t.Fatalf("asks through the tool: %+v, %v", moved, err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/named_pair_test.go b/modules/mesh-vault/cmd/mesh-vault/named_pair_test.go new file mode 100644 index 00000000..779f1a51 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/named_pair_test.go @@ -0,0 +1,44 @@ +package main + +// A consumer's own login, kept by the vault as its pair row, is named on the consumer's plan entry when the +// controller hands the consumer's secrets file to its machine's client (issue 779): the client asks for the row, and +// the rule hands it to that consumer's machine alone. + +import "testing" + +func TestANamedPairEntryHandsTheConsumersMachineItsOwnLoginAndNoOtherConsumers(t *testing.T) { + const pg = "provision/novox/postgres-database" + mirror := assignments{ + "novox.postgres": handedWith("novox", "postgres"), + "g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres=database+pair"), + "ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres#stats=database-stats+pair"), + "shanks.other": handedWith("shanks", "other", "postgres-database@novox/postgres=database+pair"), + } + v := pairVault(t, mirror) + if o := pass(t, v, pg); o.Outcome != PairMade { + t.Fatalf("a named pair entry held the provider back: %+v", o) + } + gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami/stats" + ofGitea, _, err := asks(v, "provisioner.g14", gitea) + if err != nil || ofGitea == "" { + t.Fatalf("the consumer's machine was refused its own login: %v", err) + } + if ofUmami, _, err := asks(v, "provisioner.ace", umami); err != nil || ofUmami == "" || ofUmami == ofGitea { + t.Fatalf("the second consumer's login: %q (%v)", ofUmami, err) + } + if _, _, err := asks(v, "provisioner.g14", umami); err == nil { + t.Fatal("a consumer's machine was handed another consumer's login") + } + if _, _, err := asks(v, "provisioner.shanks", gitea); err == nil { + t.Fatal("a machine was handed the login of a module that is not its own") + } + // A named entry of another form is still not a pair row's. + other := assignments{ + "novox.postgres": handedWith("novox", "postgres"), + "g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres=database+issued"), + } + w := pairVault(t, other) + if _, _, err := asks(w, "provisioner.g14", gitea); err == nil { + t.Fatal("a named entry of the issued form was handed a pair row") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go b/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go new file mode 100644 index 00000000..e266dc3d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go @@ -0,0 +1,191 @@ +package main + +// Issue 786 (ADR 0372): a machine's node-engine states its own sealing key to the vault, in its own name, at start and +// when it changes, whether or not it ever moved a secret in. The caller is the bus user the runtime proved; a changed +// key (or a first one) raises `secret-changed` for the machine's core secrets, the same key says nothing. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +func changedNames(p *published) []string { + p.mu.Lock() + defer p.mu.Unlock() + var out []string + for _, e := range p.events { + if e["event"] == ChangedEvent { + out = append(out, e["body"].(map[string]any)["name"].(string)) + } + } + return out +} + +func TestANodeEngineStatesItsOwnKeyAndAChangeRaisesItsCoreSecrets(t *testing.T) { + v := newVault(t, nil) + tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key") + // ace holds two core secrets and a leaf module's; g14 holds a core secret too. + old, _ := aSealingKey(t) + for _, m := range []MoveOf{ + {Node: "ace", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", Generation: "g-1", SealingKey: old}, + {Node: "ace", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"}, + {Node: "ace", Module: "postgres", Name: "superuser-password", Origin: "made", Generation: "g-1"}, + {Node: "g14", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"}, + } { + if _, err := moveIn(t, v, "node."+m.Node, m, movedValue); err != nil { + t.Fatal(err) + } + } + // ace never stated a key in the moves above except through the first row; forget it to be a machine that never moved. + if err := os.Remove(v.Store.nodeKeyPath("ace")); err != nil { + t.Fatal(err) + } + before := len(changedNames(v.bus)) + + pub, _ := aSealingKey(t) + if _, err := tool.runAs("controller", map[string]any{"key": pub}); err == nil { + t.Error("the controller stated a machine's key") + } + if _, err := tool.runAs("", map[string]any{"key": pub}); err == nil { + t.Error("an unproven caller stated a key") + } + if _, err := tool.runAs("node.ace", map[string]any{"key": "c2hvcnQ="}); err == nil { + t.Error("a short key was taken") + } + if _, err := tool.runAs("node.ace", map[string]any{"node": "g14", "key": pub}); err != nil { + t.Fatal(err) + } + // The machine is the caller's; an argument naming another one changes nothing. + if got, held, _ := v.Store.NodeKey("ace"); !held || got != pub { + t.Errorf("ace's key is %q, %v", got, held) + } + if _, held, _ := v.Store.NodeKey("g14"); held { + t.Error("a key was recorded for g14 by ace's engine") + } + got := changedNames(v.bus)[before:] + want := []string{"own/ace/mesh-provisioner-client/provisioner-broker", "own/ace/nats/broker"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("secret-changed was raised for %v, want %v", got, want) + } + + // The same key again changes nothing and says nothing. + if _, err := tool.runAs("node.ace", map[string]any{"key": pub}); err != nil { + t.Fatal(err) + } + if n := len(changedNames(v.bus)); n != before+2 { + t.Errorf("the same key raised %d more", n-before-2) + } + // A different key raises them again. + other, _ := aSealingKey(t) + if _, err := tool.runAs("node.ace", map[string]any{"key": other}); err != nil { + t.Fatal(err) + } + if n := len(changedNames(v.bus)); n != before+4 { + t.Errorf("a changed key raised %d, want 2", n-before-2) + } + if got, _, _ := v.Store.NodeKey("ace"); got != other { + t.Error("the changed key was not recorded") + } +} + +const nodeKeyBus = `listen: 127.0.0.1:-1 +authorization { + users = [ + { user: "novox.mesh-vault", password: "pw", permissions: { + publish: { deny: [">"] } + subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*"] } + allow_responses: true } } + { user: "node.ace", password: "pw", permissions: { + publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace"] } + subscribe: { allow: ["_INBOX.node.ace.>"] } } } + { user: "node.g14", password: "pw", permissions: { + publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14"] } + subscribe: { allow: ["_INBOX.node.g14.>"] } } } + ] +} +` + +// On a real bus whose accounts may publish only their own subject: node.ace's statement records ace's key; node.g14 +// cannot speak in ace's name, and what it states is g14's. +func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) { + file := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(file, []byte(nodeKeyBus), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(file) + if err != nil { + t.Fatal(err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + + v := newVault(t, nil) + tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key") + vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault") + if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.node-key.*", func(m *nats.Msg) { + parts := strings.Split(m.Subject, ".") + var args map[string]any + _ = json.Unmarshal(m.Data, &args) + answer := busAnswer{} + if got, err := tool.runAs(untoken(parts[len(parts)-1]), args); err != nil { + answer.Error = err.Error() + } else { + answer.Result, _ = json.Marshal(got) + } + raw, _ := json.Marshal(answer) + _ = m.Respond(raw) + }); err != nil { + t.Fatal(err) + } + _ = vaultConn.Flush() + + say := func(user, subject, key string) (string, error) { + raw, _ := json.Marshal(map[string]string{"key": key}) + msg, err := connectAs(t, s.ClientURL(), user).Request(subject, raw, 3*time.Second) + if err != nil { + return "", err + } + var a busAnswer + _ = json.Unmarshal(msg.Data, &a) + if a.Error != "" { + return a.Error, errString(a.Error) + } + return "", nil + } + aceKey, _ := aSealingKey(t) + g14Key, _ := aSealingKey(t) + if _, err := say("node.ace", "mesh.seat.mesh-vault.call.node-key.node_ace", aceKey); err != nil { + t.Fatal(err) + } + if got, held, _ := v.Store.NodeKey("ace"); !held || got != aceKey { + t.Errorf("ace's key is %q, %v", got, held) + } + // g14 may not publish on ace's subject: the bus refuses it, so the vault never hears it. + if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_ace", g14Key); err == nil { + t.Error("node.g14 spoke in ace's name") + } + if got, _, _ := v.Store.NodeKey("ace"); got != aceKey { + t.Error("ace's key was replaced by g14's") + } + if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_g14", g14Key); err != nil { + t.Fatal(err) + } + if got, held, _ := v.Store.NodeKey("g14"); !held || got != g14Key { + t.Errorf("g14's key is %q, %v", got, held) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go b/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go new file mode 100644 index 00000000..5f303955 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/owncalls_test.go @@ -0,0 +1,230 @@ +package main + +// The vault's own calls (issue 723): it asks the desk's prompt and an issuing provider's tool itself, in its own +// name, under the bus account its own runtime runs on. That the bus lets that account alone make these calls, and +// nothing else, is mesh-controller's test on a real server (internal/broker vault_calls_live_test.go); here, what +// the vault sends and takes back on those calls carries keys and sealed values only. + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/nacl/box" +) + +// vaultsAccount is the vault's bus user as its own runtime names it to a tool it calls. +const vaultsAccount = "novox.mesh-vault" + +// aMesh stands in for the bus the vault's runtime calls on: each call is answered by the desk's prompt or a +// provider's tool, named by the caller the vault's own account is, and everything that crosses is kept. +type aMesh struct { + mu sync.Mutex + desk func(caller string, args map[string]any) (map[string]any, error) + tools map[string]func(caller string, args map[string]any) (map[string]any, error) + keys []string + crossed []string +} + +func (m *aMesh) ask(key string, body any) (json.RawMessage, error) { + m.mu.Lock() + m.keys = append(m.keys, key) + m.mu.Unlock() + raw, _ := json.Marshal(body) + var args map[string]any + _ = json.Unmarshal(raw, &args) + var answer map[string]any + var err error + switch { + case strings.HasPrefix(key, "seat:node-launcher.secret@") && m.desk != nil: + answer, err = m.desk(vaultsAccount, args) + case m.tools[key] != nil: + answer, err = m.tools[key](vaultsAccount, args) + default: + err = errors.New("503 no responders") + } + out, _ := json.Marshal(answer) + m.mu.Lock() + m.crossed = append(m.crossed, string(raw), string(out)) + m.mu.Unlock() + if err != nil { + return nil, err + } + return out, nil +} + +func (m *aMesh) said() string { + m.mu.Lock() + defer m.mu.Unlock() + return strings.Join(m.crossed, "\n") +} + +// aDesk is the desk's prompt (rofi's node-launcher.secret): it answers what the operator types, sealed to the key +// it is given, and keeps what it was asked. +func aDesk(typed string, asked *[]map[string]any) func(string, map[string]any) (map[string]any, error) { + return func(caller string, args map[string]any) (map[string]any, error) { + *asked = append(*asked, args) + switch typed { + case "": + return map[string]any{"cancelled": true}, nil + case "\x00timeout": + return map[string]any{"cancelled": true, "timed_out": true}, nil + } + key, _ := args["seal_to"].(string) + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return nil, errors.New("seal_to is not a sealing key") + } + var to [32]byte + copy(to[:], raw) + sealed, err := box.SealAnonymous(nil, []byte(typed+"\n"), &to, rand.Reader) + if err != nil { + return nil, err + } + return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil + } +} + +// The vault asks the desk itself: recorded first, the prompt named by the vault's call and naming the asker, what +// is typed opened here alone; what crossed is a key and a sealed box. +func TestTheVaultAsksTheDesksPromptItselfAndOnlyASealedValueCrosses(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}}) + var asked []map[string]any + mesh := &aMesh{desk: aDesk(typedAtTheDesk, &asked)} + v.Ask = mesh.ask + + got, err := v.AskAtDesk(ControllerCaller, telegramAsk("g14/claude-code, via the console")) + if err != nil { + t.Fatal(err) + } + if got.Outcome != "given" || got.Generation != 1 || got.AskedBy != "g14/claude-code" || + got.Secret != "own/anchor/telegram/telegram-token" { + t.Errorf("answered %+v", got) + } + if len(mesh.keys) != 1 || mesh.keys[0] != "seat:node-launcher.secret@laptop" { + t.Fatalf("the vault asked %v", mesh.keys) + } + p := asked[0] + if p["asked_by"] != "g14/claude-code" || p["module"] != "telegram" || p["secret"] != "telegram-token" || + p["node"] != "anchor" || p["seal_to"] == "" || p["timeout_seconds"] != float64(DeskPromptFor) { + t.Errorf("the prompt was asked %v", p) + } + if v := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); v != typedAtTheDesk { + t.Errorf("the vault holds %q", v) + } + rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token") + if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller { + t.Errorf("the record: origin %q by %q via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia) + } + asks, _ := v.Store.Asks(10) + if len(asks) != 2 || asks[1].Event != AskOpened || asks[0].Outcome != "given" { + t.Errorf("the record of asks: %+v", asks) + } + if strings.Contains(mesh.said(), typedAtTheDesk) || strings.Contains(everything(t, v, got, asks), typedAtTheDesk) { + t.Fatal("the typed value crossed the bus, or is in something the vault said or wrote, in the clear") + } + + // Dismissed and timed out end the ask so, and keep nothing. + for typed, outcome := range map[string]string{"": "dismissed", "\x00timeout": "timed-out"} { + w := newVault(t, nil) + var a []map[string]any + w.Ask = (&aMesh{desk: aDesk(typed, &a)}).ask + got, err := w.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil || got.Outcome != outcome { + t.Errorf("%s: %+v %v", outcome, got, err) + } + if asks, _ := w.Store.Asks(1); asks[0].Outcome != outcome { + t.Errorf("%s recorded %+v", outcome, asks[0]) + } + if _, held, _ := w.Store.Read("own/anchor/telegram/telegram-token"); held { + t.Errorf("%s kept a value", outcome) + } + } + // Answered empty: the operator's answer, said so, nothing kept. + e := newVault(t, nil) + var ea []map[string]any + e.Ask = (&aMesh{desk: aDesk(" ", &ea)}).ask + if got, err := e.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err != nil || got.Outcome != "empty" { + t.Errorf("empty: %+v %v", got, err) + } + // The desk cannot be asked: refused, recorded so. + r := newVault(t, nil) + r.Ask = (&aMesh{}).ask + if _, err := r.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err == nil { + t.Error("an ask nobody answered was said to be done") + } + if asks, _ := r.Store.Asks(1); asks[0].Outcome != "refused" { + t.Errorf("an unanswered desk recorded %+v", asks[0]) + } + // Only the controller asks the vault to ask: anybody else is refused before any prompt. + n := newVault(t, nil) + var na []map[string]any + n.Ask = (&aMesh{desk: aDesk(typedAtTheDesk, &na)}).ask + for _, caller := range []string{"g14.node-tools", "g14.claude-code", "unproven", ""} { + if _, err := n.AskAtDesk(caller, telegramAsk("x")); err == nil { + t.Errorf("%q had the vault ask a desk", caller) + } + } + if len(na) != 0 { + t.Fatal("a desk was asked for a caller that is not the controller") + } +} + +// The vault asks an issuing provider's tool itself: by its name on the provider's machine, with a key made for the +// call; the tool answers the vault's account; the value opens here; what crossed is a key and a sealed box. +func TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}}) + software := "plex-token-from-the-vendor" + mesh := &aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){ + "plex.plex_issued_credential@anchor": standInProvider(&software)}} + v.Ask = mesh.ask + got, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential") + if err != nil { + t.Fatal(err) + } + if got.Secret != "provision/anchor/plex-api" || got.Generation != 1 || got.Unchanged { + t.Errorf("answered %+v", got) + } + if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software { + t.Errorf("the vault holds %q", got) + } + if strings.Contains(mesh.said(), software) || strings.Contains(everything(t, v, got), software) { + t.Fatal("the issued value crossed the bus, or is in something the vault said or wrote, in the clear") + } + if !strings.Contains(mesh.said(), `"provision":"plex-api"`) || !strings.Contains(mesh.said(), `"seal_to"`) { + t.Errorf("the tool was asked %s", mesh.said()) + } + // The same value again writes nothing. + again, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential") + if err != nil || !again.Unchanged || again.Generation != 1 { + t.Errorf("again: %+v %v", again, err) + } + // Only the controller asks the vault to take it; a tool's name that is not one is refused; a tool that is not + // answered, or answers no sealed value, keeps nothing. + for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} { + if _, err := v.TakeIssued(caller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil { + t.Errorf("%q had the vault ask a provider", caller) + } + } + for _, bad := range []string{"", "plex.other", "x@y", "a b"} { + if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", bad); err == nil { + t.Errorf("the tool %q was asked", bad) + } + } + if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_status"); err == nil { + t.Error("a tool nobody answers gave a value") + } + mesh.tools["plex.plex_issued_credential@anchor"] = func(string, map[string]any) (map[string]any, error) { + return map[string]any{"value": "in-the-clear"}, nil + } + if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil { + t.Error("a tool answering no sealed value gave a value") + } + if len(v.pending) != 0 { + t.Errorf("%d calls' keys are still kept", len(v.pending)) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go new file mode 100644 index 00000000..ac3df924 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -0,0 +1,814 @@ +package main + +// The credential a provider shares with its consumers (novox/hq ADR 0349 clause 8; ADR 0158 §4–§6, §8; design 57's +// V5, issue 677): one value per provider assignment and provision, `provision//`, minted +// and remade by the vault for all its holders together when a consumer binds or unbinds, or when it is rotated. +// +// **Where the vault sees a bind.** A recorded need names no provider (issue 677's note 7). The controller names, in +// the plan of each module it handed to the provisioner, each provision the module requires with the provider +// resolution chose (issue 760): `provisions` in `.` of the assignments mirror, the plan the module's +// client records its needs from. A consumer binds when such an entry appears and unbinds when it goes, or when its +// assignment goes. The vault reads every plan on each pass (once a minute, and when it starts). +// +// **Every holder can get it, or nothing is made** (the lead's rule on issue 677, note 10). The holders are the +// provider on its machine and every consumer bound to it. Only a module handed to the provisioner gets a value from +// the vault; one still filled the old way gets the controller's (ADR 0349 clause 17). So the vault mints or remakes +// a provider's value only when the provider's assignment is handed to the provisioner, and no consumer the vault +// knows to hold it is still filled the old way: a consumer whose pair row the move brought in (V3) and whose +// assignment is still there with no plan. Otherwise it makes nothing, and says why on each pass that changes what +// it says. A consumer filled the old way that bound after the move names no provider on the bus at all; it is the +// provider's move (design 57 B2, B3) that ends that case. +// +// **What the move brought in is adopted, and nothing is rotated early.** The move (V3) copied each pair row as +// `pair////[/]`. Rows a provider shares (ADR 0158: its own secret and +// every consumer's row made in one act) carry one generation stamp; when every row of a provider and provision +// carries the same stamp and the same value, the vault keeps that value as the provision's one credential, with its +// origin, and remakes nothing. Rows of a provider with a login per consumer (ADR 0048) carry no stamp and differ +// per pair: each consumer keeps a value of its own for ever (ADR 0158 §11, issue 677's note 12). The vault never +// makes them one shared value; a rotation (`rotate` of `provision//`) remakes each consumer's +// own apart, and each consumer's machine is handed only its own. +// +// **A provider the vault holds no row of gets its first value in its offer's form** (issue 768). Each plan entry +// names the form the provider's offer declares (`credential-form`, from the offer's `credential` in the provider's +// manifest): `shared`, one value for every holder (ADR 0158), made as `provision//`; `pair`, a +// login of its own for each consumer (ADR 0048, ADR 0158 §11), made as each consumer's pair row; `issued` by the +// provider's software (ADR 0305) or `given` by a person (ADR 0337), which the vault never makes. An entry naming no +// form (a controller from before), plans that disagree, or a form the vault does not know: nothing is made, rather +// than a guess. A provider the vault already holds rows of is unchanged by the form: it is adopted or kept per pair +// as above. +// +// **An accepted or issued value is never remade** (ADR 0158 §8, ADR 0305 §9): the vault records who holds it and +// hands it to them, and leaves the value alone. A provision whose plans name a credential per holder (issue 757, +// an entry with a name) has no one shared value and is not the vault's to make here. + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// Mirror is the controller's assignments mirror as the vault reads it: every key, and one key's value. +type Mirror struct { + Keys func() ([]string, error) + Read AssignmentReader +} + +// Shared is what the vault last made, adopted or recorded the provision's one credential for: its holders and the +// generation they hold. Never a value. +type Shared struct { + Name string `json:"name"` + Provider string `json:"provider"` + Provision string `json:"provision"` + ProviderModule string `json:"provider-module,omitempty"` + // Consumers are the binds holding it: `/`, and `/` when bound under a local name. + Consumers []string `json:"consumers"` + Generation int `json:"generation"` + At time.Time `json:"at"` + // Adopted is the controller's generation stamp of the pair rows the value was adopted from, when it was. + Adopted string `json:"adopted,omitempty"` +} + +// holderNodes are the machines holding it: the provider's and every consumer's. +func (s Shared) holderNodes() []string { + out := []string{s.Provider} + for _, c := range s.Consumers { + out = append(out, strings.SplitN(c, "/", 2)[0]) + } + return out +} + +func (s *Store) sharedPath(name string) string { + return filepath.Join(s.dir, "shared", url.PathEscape(name)+".json") +} + +// ReadShared is the record of who holds the provision's one credential name, and whether there is one. +func (s *Store) ReadShared(name string) (Shared, bool, error) { + if err := validName(name); err != nil { + return Shared{}, false, err + } + raw, err := os.ReadFile(s.sharedPath(name)) + if errors.Is(err, os.ErrNotExist) { + return Shared{}, false, nil + } + if err != nil { + return Shared{}, false, err + } + var sh Shared + if err := json.Unmarshal(raw, &sh); err != nil { + return Shared{}, false, fmt.Errorf("the record of who holds %s is not readable: %w", name, err) + } + return sh, true, nil +} + +// WriteShared keeps the record of who holds a provision's one credential. +func (s *Store) WriteShared(sh Shared) error { + if err := validName(sh.Name); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(s.dir, "shared"), 0o700); err != nil { + return err + } + sort.Strings(sh.Consumers) + raw, err := json.MarshalIndent(sh, "", " ") + if err != nil { + return err + } + return writeWhole(s.sharedPath(sh.Name), append(raw, '\n')) +} + +// pairKey is one provider assignment and provision. +type pairKey struct{ provider, provision string } + +func (k pairKey) name() string { return "provision/" + k.provider + "/" + k.provision } + +// binds is what the plans say of one provider and provision: its module, who binds it, whether a plan names a +// credential per holder for it, and the credential forms the plans name for it ("" for an entry naming none). +type binds struct { + providerModules map[string]bool + consumers map[string]bool + perHolder bool + forms map[string]bool +} + +// The credential forms a plan entry names (issue 768), as the provider's offer declares them. +const ( + // FormShared is one value every holder shares (ADR 0158): the offer's credential is one of its own secrets. + FormShared = "shared" + // FormPair is a login of its own for each consumer (ADR 0048, ADR 0158 §11): the offer says no credential. + FormPair = "pair" + // FormIssued is a value the provider's software issues (ADR 0305): the mesh never makes it. + FormIssued = "issued" + // FormGiven is a value only a person gives (ADR 0337): the mesh never makes it. + FormGiven = "given" +) + +// credentialForms are every form a plan entry may name. +var credentialForms = []string{FormShared, FormPair, FormIssued, FormGiven} + +// form is the one form the plans name for k, or why the vault cannot trust one. +func (b *binds) form(k pairKey) (string, string) { + if b == nil || len(b.forms) == 0 || b.forms[""] { + return "", unknownForm(k) + } + var named []string + for f := range b.forms { + if !slices.Contains(credentialForms, f) { + return "", fmt.Sprintf("a plan names %q for %s from %s, which is not a credential form the vault knows "+ + "(%s); it makes nothing", f, k.provision, k.provider, strings.Join(credentialForms, ", ")) + } + named = append(named, f) + } + if len(named) > 1 { + sort.Strings(named) + return "", fmt.Sprintf("the plans binding %s from %s disagree on its credential form (%s); it makes nothing", + k.provision, k.provider, strings.Join(named, ", ")) + } + return named[0], "" +} + +// pairRow is one row the move brought in. +type pairRow struct { + consumer, module string + rec Record +} + +// bindings is what one pass read: every plan's bound provisions, which assignments there are, and which of them +// are handed to the provisioner. +type bindings struct { + bound map[pairKey]*binds + assigned map[string]bool + handed map[string]bool +} + +// plannedProvisions is as much of an assignment as the pass reads. +type plannedProvisions struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Name string `json:"name"` + Provision string `json:"provision"` + Local string `json:"local"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + CredentialForm string `json:"credential-form"` + // Contributes is what the consumer tells its provider (issue 776); the vault does not act on it. + Contributes map[string]any `json:"contributes"` + } `json:"provisions"` + } `json:"provisioning"` +} + +// readBindings reads every assignment. An assignment that cannot be read stops the pass: a bind missed would read +// as an unbind, and remake a value its holder still uses. +func readBindings(m Mirror) (bindings, error) { + b := bindings{bound: map[pairKey]*binds{}, assigned: map[string]bool{}, handed: map[string]bool{}} + if m.Keys == nil || m.Read == nil { + return b, errors.New("the vault cannot read the assignments, so it cannot see who binds a provider") + } + keys, err := m.Keys() + if err != nil { + return b, fmt.Errorf("the assignments' keys could not be read: %w", err) + } + for _, key := range keys { + raw, err := m.Read(key) + if err != nil { + return b, fmt.Errorf("the assignment %s could not be read: %w", key, err) + } + if raw == nil { + continue + } + var a plannedProvisions + if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key { + return b, fmt.Errorf("the assignment %s is not the controller's", key) + } + b.assigned[key] = true + if a.Provisioning == nil { + continue + } + b.handed[key] = true + for _, p := range a.Provisioning.Provisions { + if !namePart.MatchString(p.Provider) || !namePart.MatchString(p.Provision) { + continue + } + k := pairKey{p.Provider, p.Provision} + if b.bound[k] == nil { + b.bound[k] = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{}, + forms: map[string]bool{}} + } + b.bound[k].forms[p.CredentialForm] = true + if p.ProviderModule != "" { + b.bound[k].providerModules[p.ProviderModule] = true + } + // A name on an entry of the login-per-consumer form is that login's file (issue 779), not a value issued + // for one holder: the provider still gets its logins as rows. + if p.Name != "" && p.CredentialForm != FormPair { + b.bound[k].perHolder = true + } + b.bound[k].consumers[consumerOf(a.Node, a.Module, p.Local)] = true + } + } + return b, nil +} + +func consumerOf(node, module, local string) string { + if local == "" { + return node + "/" + module + } + return node + "/" + module + "/" + local +} + +// pairRows is every row the move brought in, by provider and provision. +func (v *Vault) pairRows() (map[pairKey][]pairRow, error) { + all, err := v.Store.List() + if err != nil { + return nil, err + } + out := map[pairKey][]pairRow{} + for _, rec := range all { + parts := strings.Split(rec.Name, "/") + if parts[0] != "pair" || (len(parts) != 5 && len(parts) != 6) { + continue + } + k := pairKey{parts[1], parts[2]} + out[k] = append(out[k], pairRow{consumer: parts[3], module: parts[4], rec: rec}) + } + return out, nil +} + +// PairOutcome is what one pass did, or did not do, for one provider and provision. +type PairOutcome struct { + Name string `json:"name"` + Outcome string `json:"outcome"` + Generation int `json:"generation,omitempty"` + Why string `json:"why,omitempty"` +} + +// What a pass did for a provider and provision. +const ( + PairMade = "made" + PairRemade = "remade" + PairAdopted = "adopted" + PairRecorded = "recorded" + PairUnchanged = "unchanged" + PairHeld = "held" + PairPerPair = "waits-for-rotation" + PairPerHolder = "per-holder" +) + +// sharedHolders is who holds a provider's value now: its provider module and the consumers bound to it. +func sharedHolders(k pairKey, b *binds) (string, []string) { + var modules, consumers []string + if b != nil { + for m := range b.providerModules { + modules = append(modules, m) + } + for c := range b.consumers { + consumers = append(consumers, c) + } + } + sort.Strings(modules) + sort.Strings(consumers) + return strings.Join(modules, ","), consumers +} + +// heldBack says why the vault may not make a value for k now, or "": a holder that would never get it. +func heldBack(k pairKey, b *binds, rows []pairRow, seen bindings) string { + if b == nil || len(b.providerModules) == 0 { + return fmt.Sprintf("no plan names the module serving %s on %s, so the vault cannot tell the provider gets "+ + "a value it makes", k.provision, k.provider) + } + for m := range b.providerModules { + if !seen.handed[k.provider+"."+m] { + return fmt.Sprintf("the provider %s on %s is not handed to the provisioner, so it would never get a "+ + "value the vault makes (ADR 0349 clause 17)", m, k.provider) + } + } + for _, r := range rows { + key := r.consumer + "." + r.module + if seen.assigned[key] && !seen.handed[key] { + return fmt.Sprintf("%s on %s holds %s from %s and is not handed to the provisioner, so it would never "+ + "get a value the vault makes (ADR 0349 clause 17)", r.module, r.consumer, k.provision, k.provider) + } + } + return "" +} + +// oneStamp is the generation stamp every row shares, with their one origin and value, when they all share one; or +// why not. +func (v *Vault) oneStamp(rows []pairRow) (stamp, origin, value string, madeAt time.Time, why string) { + for i, r := range rows { + if r.rec.MovedFrom == nil || r.rec.MovedFrom.Generation == "" { + return "", "", "", time.Time{}, fmt.Sprintf("%s carries no generation stamp: a login of its own (ADR 0048)", + r.rec.Name) + } + opened, err := seal.Open(r.rec.AtRest, v.Own.PrivateKey) + if err != nil { + return "", "", "", time.Time{}, fmt.Sprintf("%s does not open with the vault's key: %v", r.rec.Name, err) + } + o := r.rec.Origin + if o == "" { + o = OriginMade + } + if i == 0 { + stamp, origin, value, madeAt = r.rec.MovedFrom.Generation, o, opened, r.rec.MadeAt + continue + } + if r.rec.MovedFrom.Generation != stamp || opened != value || o != origin { + return "", "", "", time.Time{}, fmt.Sprintf("the rows of one provider and provision carry different "+ + "generations, values or origins (%s among them): logins of their own (ADR 0048)", r.rec.Name) + } + if r.rec.MadeAt.Before(madeAt) { + madeAt = r.rec.MadeAt + } + } + return stamp, origin, value, madeAt, "" +} + +// PairPass looks at every provider and provision the plans bind or the move brought in, and makes, remakes or +// adopts each one's shared value as the rules above say. It returns what it did for each; an error is a pass that +// could not read what it needs, and did nothing. +func (v *Vault) PairPass() ([]PairOutcome, error) { + seen, err := readBindings(v.Assignments) + if err != nil { + return nil, err + } + rows, err := v.pairRows() + if err != nil { + return nil, err + } + keys := map[pairKey]bool{} + for k := range seen.bound { + keys[k] = true + } + for k := range rows { + keys[k] = true + } + all, err := v.Store.List() + if err != nil { + return nil, err + } + for _, rec := range all { + parts := strings.Split(rec.Name, "/") + if len(parts) == 3 && parts[0] == "provision" { + if _, recorded, _ := v.Store.ReadShared(rec.Name); recorded { + keys[pairKey{parts[1], parts[2]}] = true + } + } + } + ordered := make([]pairKey, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i].name() < ordered[j].name() }) + var out []PairOutcome + var failed []error + for _, k := range ordered { + o, err := v.pairOne(k, seen.bound[k], rows[k], seen) + if err != nil { + failed = append(failed, fmt.Errorf("%s: %w", k.name(), err)) + o = PairOutcome{Name: k.name(), Outcome: PairHeld, Why: err.Error()} + } + out = append(out, o) + } + return out, errors.Join(failed...) +} + +// pairOne is one provider and provision's part of a pass. +func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (PairOutcome, error) { + name := k.name() + if b != nil && b.perHolder { + return PairOutcome{Name: name, Outcome: PairPerHolder, + Why: "a plan names a credential per holder for it (issue 757): there is no one shared value"}, nil + } + rec, held, err := v.Store.Read(name) + if err != nil { + return PairOutcome{}, err + } + sh, recorded, err := v.Store.ReadShared(name) + if err != nil { + return PairOutcome{}, err + } + // The provider's module, when no plan names it any more (its last consumer unbound), is the one recorded. + if b == nil { + b = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{}} + } + if len(b.providerModules) == 0 && recorded && sh.ProviderModule != "" { + for _, m := range strings.Split(sh.ProviderModule, ",") { + b.providerModules[m] = true + } + } + providerModule, consumers := sharedHolders(k, b) + record := func(generation int, adopted string) error { + return v.Store.WriteShared(Shared{Name: name, Provider: k.provider, Provision: k.provision, + ProviderModule: providerModule, Consumers: consumers, Generation: generation, At: v.Now().UTC(), + Adopted: adopted}) + } + switch { + case !held && len(rows) > 0: + stamp, origin, value, madeAt, why := v.oneStamp(rows) + if why != "" { + // A consumer bound since the move, that has no row yet, gets a login of its own; the rows there are + // never touched (issue 771). + if form, formWhy := b.form(k); formWhy == "" && form == FormPair { + have := map[string]bool{} + for _, r := range rows { + have[strings.Join(strings.Split(r.rec.Name, "/")[3:], "/")] = true + } + var missing []string + for _, c := range consumers { + if !have[c] { + missing = append(missing, c) + } + } + if len(missing) > 0 { + if held := heldBack(k, b, rows, seen); held != "" { + return PairOutcome{Name: name, Outcome: PairHeld, Why: held}, nil + } + return v.firstLogins(k, missing) + } + } + return PairOutcome{Name: name, Outcome: PairPerPair, Why: why + "; each consumer keeps a value of its " + + "own for ever (ADR 0048, ADR 0158 §11), and a rotation remakes each one's own, never one shared value"}, nil + } + // Adopted as it is: the holders the rows name hold it, and a bind since then is a remake on the next pass. + changed, err := v.keepWith(name, value, "the vault", "", origin, keepOptions{neverReplace: true, madeAt: madeAt, + movedFrom: &MovedFrom{By: "the move's pair rows", Generation: stamp}}) + value = "" + if err != nil { + return PairOutcome{}, err + } + var fromRows []string + for _, r := range rows { + parts := strings.Split(r.rec.Name, "/") + fromRows = append(fromRows, strings.Join(parts[3:], "/")) + } + consumers = fromRows + if err := record(changed.Generation, stamp); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: name, Outcome: PairAdopted, Generation: changed.Generation}, nil + case !held: + if len(consumers) == 0 { + return PairOutcome{Name: name, Outcome: PairUnchanged}, nil + } + if why := heldBack(k, b, rows, seen); why != "" { + return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil + } + form, why := b.form(k) + switch { + case why != "": + return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil + case form == FormShared: + changed, err := v.mintShared(name) + if err != nil { + return PairOutcome{}, err + } + if err := record(changed.Generation, ""); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: name, Outcome: PairMade, Generation: changed.Generation}, nil + case form == FormPair: + return v.firstLogins(k, consumers) + case form == FormIssued: + return PairOutcome{Name: name, Outcome: PairHeld, Why: "its credential is issued by the provider's " + + "software (ADR 0305), which the vault never makes; `secret issued` brings it in"}, nil + } + return PairOutcome{Name: name, Outcome: PairHeld, Why: "its credential is given by a person (ADR 0337), " + + "which the vault never makes; `secret accept` brings it in"}, nil + case rec.Origin == OriginAccepted || rec.Origin == OriginIssued: + // Never remade: who holds it is recorded, so each gets it on request. + if recorded && slices.Equal(sh.Consumers, consumers) && sh.ProviderModule == providerModule { + return PairOutcome{Name: name, Outcome: PairUnchanged, Generation: rec.Generation}, nil + } + if err := record(rec.Generation, sh.Adopted); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: name, Outcome: PairRecorded, Generation: rec.Generation, + Why: "a value " + rec.Origin + " is not remade (ADR 0158 §8, ADR 0305 §9); its holders are recorded"}, nil + case !recorded: + // Held but never recorded (made by a rotation before any plan bound it): who binds it now holds it. + if err := record(rec.Generation, ""); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: name, Outcome: PairRecorded, Generation: rec.Generation}, nil + case slices.Equal(sh.Consumers, consumers): + return PairOutcome{Name: name, Outcome: PairUnchanged, Generation: rec.Generation}, nil + } + if why := heldBack(k, b, rows, seen); why != "" { + return PairOutcome{Name: name, Outcome: PairHeld, Generation: rec.Generation, Why: why}, nil + } + changed, err := v.mintShared(name) + if err != nil { + return PairOutcome{}, err + } + if err := record(changed.Generation, ""); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: name, Outcome: PairRemade, Generation: changed.Generation}, nil +} + +// mintShared makes the provision's one credential anew, for all its holders in one act: a new generation and +// `secret-changed`, which tells every holder to ask again. +func (v *Vault) mintShared(name string) (Changed, error) { + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return Changed{}, err + } + return v.keep(name, base64.RawURLEncoding.EncodeToString(raw), "the vault", "", OriginMade) +} + +// firstLogins makes each bound consumer of a per-login provider the vault holds no row of its first login of its +// own (ADR 0048, ADR 0158 §11, issue 768): `pair////[/]`, a value +// apart for each, never one shared value. Once made they are rows, and every later pass keeps them per pair. +func (v *Vault) firstLogins(k pairKey, consumers []string) (PairOutcome, error) { + var failed []error + for _, c := range consumers { + parts := strings.SplitN(c, "/", 3) + local := "" + if len(parts) == 3 { + local = parts[2] + } + row, err := PairSecret(k.provider, k.provision, parts[0], parts[1], local) + if err != nil { + failed = append(failed, err) + continue + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return PairOutcome{}, err + } + if _, err := v.keepWith(row, base64.RawURLEncoding.EncodeToString(raw), "the vault", "", OriginMade, + keepOptions{neverReplace: true}); err != nil { + failed = append(failed, fmt.Errorf("%s: %w", row, err)) + } + } + if err := errors.Join(failed...); err != nil { + return PairOutcome{}, err + } + return PairOutcome{Name: k.name(), Outcome: PairMade, Why: fmt.Sprintf("a first login of its own for each of "+ + "%d consumers (ADR 0048, ADR 0158 §11)", len(consumers))}, nil +} + +// unknownForm is why the vault makes nothing for a provider and provision it has no row of while no plan names its +// offer's credential form: whether the offer shares one credential (ADR 0158) or keeps a login per consumer (ADR +// 0048) is the offer's, and a plan from a controller before issue 768 does not say which. +func unknownForm(k pairKey) string { + return fmt.Sprintf("the vault holds no row of %s from %s, and no plan says whether its offer shares one credential "+ + "(ADR 0158) or keeps a login per consumer (ADR 0048, §11); it makes nothing until it knows", k.provision, + k.provider) +} + +// rotatePerPair is the rotation of a provider whose consumers each keep a login of their own (ADR 0048, ADR 0158 +// §11): every consumer's own value is remade apart, each a value of its own, and no shared value is made. A value +// accepted or issued is left as it is. Refused whole while any holder would never get its new value. Narrowed to one +// consumer, only that consumer's own logins are remade (issue 767), and a consumer that holds none is refused. +func (v *Vault) rotatePerPair(name string, one Narrow, by string) (Changed, error) { + parts := strings.Split(name, "/") + k := pairKey{parts[1], parts[2]} + rows, err := v.pairRows() + if err != nil { + return Changed{}, err + } + if len(rows[k]) == 0 { + return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s, and %s", name, unknownForm(k)) + } + if _, _, _, _, why := v.oneStamp(rows[k]); why == "" { + return Changed{}, fmt.Errorf("not rotated: the rows of %s share one value not adopted yet; the next pass "+ + "adopts it, then rotate it", name) + } + if why := v.rotationHeldBack(name); why != "" { + return Changed{}, fmt.Errorf("not rotated: %s", why) + } + if !slices.ContainsFunc(rows[k], one.holds) { + return Changed{}, fmt.Errorf("not rotated: %s holds no login of %s from %s", one, k.provision, k.provider) + } + out := Changed{Name: name, At: v.Now().UTC(), By: by} + var failed []error + for _, r := range rows[k] { + if !one.holds(r) { + continue + } + if r.rec.Origin == OriginAccepted || r.rec.Origin == OriginIssued { + continue + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return out, err + } + changed, err := v.keep(r.rec.Name, base64.RawURLEncoding.EncodeToString(raw), by, ControllerCaller, OriginMade) + if err != nil { + failed = append(failed, fmt.Errorf("%s: %w", r.rec.Name, err)) + continue + } + out.Each = append(out.Each, changed) + } + return out, errors.Join(failed...) +} + +// rotationHeldBack says why a rotation of a provision's one credential would leave a holder behind, or "". +func (v *Vault) rotationHeldBack(name string) string { + parts := strings.Split(name, "/") + if len(parts) != 3 || parts[0] != "provision" || v.Assignments.Keys == nil { + return "" + } + k := pairKey{parts[1], parts[2]} + seen, err := readBindings(v.Assignments) + if err != nil { + return err.Error() + } + rows, err := v.pairRows() + if err != nil { + return err.Error() + } + return heldBack(k, seen.bound[k], rows[k], seen) +} + +// sharedRefusal says why caller (`provisioner.`) may not have the provision's one credential name, or "": it +// is handed to the machines the vault made it for, the provider's and each bound consumer's, while the provider's +// assignment there is handed to the provisioner, or a consumer's plan there still names it from that provider. +func sharedRefusal(store *Store, read AssignmentReader, node, provider, provision string) string { + name := "provision/" + provider + "/" + provision + if store == nil { + return "the vault keeps no record of who holds " + name + } + sh, ok, err := store.ReadShared(name) + if err != nil { + return err.Error() + } + if !ok { + return fmt.Sprintf("the vault made %s for nobody yet: it is handed to the machines its provider and its "+ + "consumers run on once their plans bind it (ADR 0349 clause 8)", name) + } + if !slices.Contains(sh.holderNodes(), node) { + return fmt.Sprintf("%s is held by %s, not by a module on %s", name, strings.Join(sh.holderNodes(), ", "), node) + } + if read == nil { + return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name" + } + if node == provider && sh.ProviderModule != "" { + for _, m := range strings.Split(sh.ProviderModule, ",") { + if raw, err := read(node + "." + m); err == nil && raw != nil { + var a plannedProvisions + if json.Unmarshal(raw, &a) == nil && a.Node == node && a.Module == m && a.Provisioning != nil { + return "" + } + } + } + } + for _, c := range sh.Consumers { + parts := strings.SplitN(c, "/", 3) + if parts[0] != node { + continue + } + raw, err := read(node + "." + parts[1]) + if err != nil || raw == nil { + continue + } + var a plannedProvisions + if json.Unmarshal(raw, &a) != nil || a.Node != node || a.Module != parts[1] || a.Provisioning == nil { + continue + } + for _, p := range a.Provisioning.Provisions { + if p.Name == "" && p.Provision == provision && p.Provider == provider { + return "" + } + } + } + return fmt.Sprintf("no module on %s that the vault made %s for is handed to the provisioner and bound to it now", + node, name) +} + +// pairPass is one minute's PairPass, saying each outcome when it differs from the last pass's, so a provider held +// back is said once and again when it changes, not every minute. +func (e *Env) pairPass() { + if e.Vault == nil { + return + } + outcomes, err := e.Vault.PairPass() + e.mu.Lock() + defer e.mu.Unlock() + if err != nil && err.Error() != e.lastPairFailure { + say("the pass over the providers' shared credentials did not finish: %v", err) + } + e.lastPairFailure = "" + if err != nil { + e.lastPairFailure = err.Error() + } + if e.pairsSaid == nil { + e.pairsSaid = map[string]string{} + } + for _, o := range outcomes { + said := o.Outcome + ": " + o.Why + if o.Outcome == PairUnchanged || e.pairsSaid[o.Name] == said { + continue + } + e.pairsSaid[o.Name] = said + if o.Why == "" { + say("%s %s (generation %d)", o.Name, o.Outcome, o.Generation) + } else { + say("%s %s: %s", o.Name, o.Outcome, o.Why) + } + } +} + +// pairRowRefusal says why caller's machine node may not have the pair row name (`pair/// +// /[/]`, a consumer's own login, ADR 0048), or "": it is handed to that consumer's machine +// while the consumer's plan there names the provision from that provider under that local name, and to the +// provider's machine while the provider module that plan names is handed to the provisioner there. Another +// consumer, on the same machine or not, is refused: each gets only its own. +func pairRowRefusal(read AssignmentReader, node string, parts []string) string { + provider, provision, consumer, module, local := parts[1], parts[2], parts[3], parts[4], "" + if len(parts) == 6 { + local = parts[5] + } + if node != consumer && node != provider { + return fmt.Sprintf("%s is the login of %s on %s from %s, and %s is neither", strings.Join(parts, "/"), + module, consumer, provider, node) + } + if read == nil { + return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name" + } + raw, err := read(consumer + "." + module) + if err != nil || raw == nil { + return fmt.Sprintf("%s is not on %s", module, consumer) + } + var a plannedProvisions + if json.Unmarshal(raw, &a) != nil || a.Node != consumer || a.Module != module { + return fmt.Sprintf("the assignment %s.%s is not the controller's", consumer, module) + } + if a.Provisioning == nil { + return fmt.Sprintf("%s on %s is not handed to the provisioner, so its login reaches it in its declaration "+ + "(ADR 0349 clause 17)", module, consumer) + } + for _, p := range a.Provisioning.Provisions { + // An unnamed entry, or a named one of the same form (the consumer's login file, issue 779): one rule. + if (p.Name != "" && p.CredentialForm != FormPair) || p.Provision != provision || + p.Provider != provider || p.Local != local { + continue + } + if node == consumer { + return "" + } + if p.ProviderModule != "" { + if praw, err := read(provider + "." + p.ProviderModule); err == nil && praw != nil { + var pa plannedProvisions + if json.Unmarshal(praw, &pa) == nil && pa.Node == provider && pa.Provisioning != nil { + return "" + } + } + } + return fmt.Sprintf("the provider of %s on %s is not handed to the provisioner", provision, provider) + } + return fmt.Sprintf("%s's plan on %s does not name %s from %s under that name", module, consumer, provision, provider) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair_test.go b/modules/mesh-vault/cmd/mesh-vault/pair_test.go new file mode 100644 index 00000000..4ec4ad0f --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/pair_test.go @@ -0,0 +1,487 @@ +package main + +// ADR 0349's provider test, the vault's part (design 57 V5, issue 677), with a stand-in provider: a consumer binding +// to it remakes its one credential without the provider asking, and a consumer unbinding remakes it for the holders +// left; every holder gets it on request after `secret-changed`; an accepted or issued value is not remade; what the +// move brought in is adopted when its rows share a generation stamp, and otherwise waits for the provider's +// rotation; and no value is made that a holder still filled the old way would never get. + +import ( + "errors" + "sort" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// keys is every key of a stand-in mirror. +func (a assignments) keys() ([]string, error) { + var out []string + for k := range a { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// handedWith is an assignment handed to the provisioner, its plan naming each provision given as +// provision@provider/provider-module[#local][=name][+credential-form]. +func handedWith(node, module string, provisions ...string) string { + var entries []string + for _, p := range provisions { + form := "" + if i := strings.Index(p, "+"); i >= 0 { + p, form = p[:i], p[i+1:] + } + name := "" + if i := strings.Index(p, "="); i >= 0 { + p, name = p[:i], p[i+1:] + } + local := "" + if i := strings.Index(p, "#"); i >= 0 { + p, local = p[:i], p[i+1:] + } + provision, rest, _ := strings.Cut(p, "@") + provider, providerModule, _ := strings.Cut(rest, "/") + e := `{"provision":"` + provision + `","provider":"` + provider + `","provider-module":"` + providerModule + `"` + if local != "" { + e += `,"local":"` + local + `"` + } + if name != "" { + e += `,"name":"` + name + `","path":"/s/` + name + `"` + } + if form != "" { + e += `,"credential-form":"` + form + `"` + } + entries = append(entries, e+"}") + } + return `{"node":"` + node + `","module":"` + module + `","provisioning":{"templates":{"source":"x","digest":"y"},` + + `"files":[],"provisions":[` + strings.Join(entries, ",") + `]}}` +} + +// unmoved is an assignment the controller has not handed to the provisioner. +func unmoved(node, module string) string { + return `{"node":"` + node + `","module":"` + module + `"}` +} + +const standIn = "provision/anchor/stand-in-api" + +// pairVault is a vault reading mirror for its binds and its rule. +func pairVault(t *testing.T, mirror assignments) testVault { + t.Helper() + v := newVault(t, nil) + v.Askers = Declared{Read: mirror.read, Store: v.Store} + v.Assignments = Mirror{Keys: mirror.keys, Read: mirror.read} + return v +} + +// pass runs one pass and is the outcome for name. +func pass(t *testing.T, v testVault, name string) PairOutcome { + t.Helper() + outcomes, err := v.PairPass() + if err != nil { + t.Fatal(err) + } + for _, o := range outcomes { + if o.Name == name { + return o + } + } + t.Fatalf("the pass said nothing of %s: %+v", name, outcomes) + return PairOutcome{} +} + +// asks is what caller is handed for name, opened, or the refusal. +func asks(v testVault, caller, name string) (string, int, error) { + key, _ := seal.GenerateKeyPair() + a, err := v.Get(caller, name, key.PublicKey) + if err != nil { + return "", 0, err + } + got, err := seal.Open(a.Sealed, key.PrivateKey) + return got, a.Generation, err +} + +// changedSaid is how many times `secret-changed` was said for name, and that none carried a value. +func changedSaid(t *testing.T, v testVault, name, value string) int { + t.Helper() + v.bus.mu.Lock() + defer v.bus.mu.Unlock() + n := 0 + for i, e := range v.bus.events { + if value != "" && strings.Contains(v.bus.raw[i], value) { + t.Fatalf("the bus carried the value: %s", v.bus.raw[i]) + } + if e["event"] == ChangedEvent && e["body"].(map[string]any)["name"] == name { + n++ + } + } + return n +} + +func TestAConsumerBindingOrUnbindingRemakesTheProvidersOneCredentialForEveryHolder(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "shared-from-the-move", "stamp-1", OriginMade) + + // The move's shared value is adopted, and said to have changed in the vault. + if o := pass(t, v, standIn); o.Outcome != PairAdopted || o.Generation != 1 { + t.Fatalf("the first pass: %+v", o) + } + first, _, err := asks(v, "provisioner.anchor", standIn) + if err != nil { + t.Fatalf("the provider's machine was refused its credential: %v", err) + } + if got, _, err := asks(v, "provisioner.g14", standIn); err != nil || got != first { + t.Fatalf("the consumer got %q (%v), the provider %q", got, err, first) + } + if first != "shared-from-the-move" || changedSaid(t, v, standIn, "") != 1 { + t.Fatal("the adopted value is not the move's, or secret-changed was not said once for it") + } + if _, _, err := asks(v, "provisioner.ace", standIn); err == nil { + t.Fatal("a machine holding nothing of it was handed the credential") + } + + // Nothing changed: nothing is remade. + if o := pass(t, v, standIn); o.Outcome != PairUnchanged { + t.Fatalf("a pass with no bind or unbind: %+v", o) + } + + // A second consumer binds: the vault remakes it without the provider asking, one new value for all three. + mirror["ace.other"] = handedWith("ace", "other", "stand-in-api@anchor/stand-in") + if o := pass(t, v, standIn); o.Outcome != PairRemade || o.Generation != 2 { + t.Fatalf("a consumer binding: %+v", o) + } + second, gen, _ := asks(v, "provisioner.anchor", standIn) + if second == first || gen != 2 { + t.Fatal("the value was not remade when a consumer bound") + } + for _, c := range []string{"provisioner.g14", "provisioner.ace"} { + if got, _, err := asks(v, c, standIn); err != nil || got != second { + t.Fatalf("%s got %q (%v) after the bind, not the one new value", c, got, err) + } + } + if changedSaid(t, v, standIn, "") != 2 { + t.Fatal("secret-changed was not said for the remade value") + } + + // It unbinds (its plan no longer names it): remade for the holders left, and it is handed nothing more. + mirror["ace.other"] = handedWith("ace", "other") + if o := pass(t, v, standIn); o.Outcome != PairRemade || o.Generation != 3 { + t.Fatalf("a consumer unbinding: %+v", o) + } + third, _, _ := asks(v, "provisioner.anchor", standIn) + if got, _, _ := asks(v, "provisioner.g14", standIn); third == second || got != third { + t.Fatal("the holders left do not share one new value after the unbind") + } + if _, _, err := asks(v, "provisioner.ace", standIn); err == nil { + t.Fatal("a consumer that unbound was still handed the credential") + } + + // Its assignment goes: an unbind too. + delete(mirror, "g14.app") + if o := pass(t, v, standIn); o.Outcome != PairRemade { + t.Fatalf("a consumer taken off its machine: %+v", o) + } + if _, _, err := asks(v, "provisioner.g14", standIn); err == nil { + t.Fatal("a machine whose consumer left was still handed the credential") + } +} + +// Two local names of one provision on one consumer are two binds of the same value; a credential per holder (issue +// 757) has no one shared value. +func TestLocalNamesAreBindsAndAPerHolderCredentialIsNotShared(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in#reader"), + "novox.gitea": handedWith("novox", "gitea"), + "g14.claude-code": handedWith("g14", "claude-code", "gitea-read@novox/gitea=gitea-read"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade, "reader") + pass(t, v, standIn) + mirror["g14.app"] = handedWith("g14", "app", "stand-in-api@anchor/stand-in#reader", + "stand-in-api@anchor/stand-in#writer") + if o := pass(t, v, standIn); o.Outcome != PairRemade { + t.Fatalf("a second local name binding: %+v", o) + } + if o := pass(t, v, "provision/novox/gitea-read"); o.Outcome != PairPerHolder { + t.Fatalf("a credential per holder: %+v", o) + } + if _, held, _ := v.Store.Read("provision/novox/gitea-read"); held { + t.Fatal("a shared value was made for a credential issued per holder") + } +} + +// An accepted value is opened by the vault alone and not remade (ADR 0158 §8), nor is one the provider's software +// issued (ADR 0305 §9): a bind records its holder, who gets the value as it is. +func TestAnAcceptedOrIssuedValueIsNotRemadeOnABind(t *testing.T) { + for _, origin := range []string{OriginAccepted, OriginIssued} { + t.Run(origin, func(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + if _, err := v.keep(standIn, "given-by-a-person", "person.jochen", ControllerCaller, origin); err != nil { + t.Fatal(err) + } + if o := pass(t, v, standIn); o.Outcome != PairRecorded || o.Generation != 1 { + t.Fatalf("the first pass: %+v", o) + } + mirror["ace.other"] = handedWith("ace", "other", "stand-in-api@anchor/stand-in") + if o := pass(t, v, standIn); o.Outcome != PairRecorded || o.Generation != 1 { + t.Fatalf("a bind to a value %s: %+v", origin, o) + } + for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} { + if got, gen, err := asks(v, c, standIn); err != nil || got != "given-by-a-person" || gen != 1 { + t.Fatalf("%s got %q generation %d (%v)", c, got, gen, err) + } + } + }) + } +} + +// movedRow is a pair row as the move (V3) keeps it: its value, the controller's generation stamp and origin. +func movedRow(t *testing.T, v testVault, consumer, module, value, stamp, origin string, local ...string) { + t.Helper() + name, err := PairSecret("anchor", "stand-in-api", consumer, module, strings.Join(local, "")) + if err != nil { + t.Fatal(err) + } + if _, err := v.keepWith(name, value, "node."+consumer, "", origin, keepOptions{neverReplace: true, + madeAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), + movedFrom: &MovedFrom{By: "node." + consumer, Generation: stamp}}); err != nil { + t.Fatal(err) + } +} + +// Rows a provider shares carry one generation stamp (ADR 0158): the vault adopts their value as the provision's one +// credential, keeps its origin and when it was made, and remakes nothing, so the move rotates no credential. +func TestPairRowsSharingAGenerationStampAreAdoptedAndNothingIsRemade(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "the-value-they-share", "20260901T000000-1", OriginMade) + movedRow(t, v, "ace", "other", "the-value-they-share", "20260901T000000-1", OriginMade) + + if o := pass(t, v, standIn); o.Outcome != PairAdopted || o.Generation != 1 { + t.Fatalf("stamped rows: %+v", o) + } + for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} { + if got, _, err := asks(v, c, standIn); err != nil || got != "the-value-they-share" { + t.Fatalf("%s got %q (%v), not the value it had", c, got, err) + } + } + rec, _, _ := v.Store.Read(standIn) + if rec.Origin != OriginMade || !rec.MadeAt.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) || + rec.MovedFrom == nil || rec.MovedFrom.Generation != "20260901T000000-1" { + t.Fatalf("the adopted value does not say where it came from: %+v", rec) + } + if o := pass(t, v, standIn); o.Outcome != PairUnchanged { + t.Fatalf("the pass after the adoption: %+v", o) + } + if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 { + t.Fatal("the adopted value was remade with no bind or unbind") + } +} + +// Rows of a provider with a login per consumer (ADR 0048) carry no stamp: each consumer keeps a value of its own +// for ever (ADR 0158 §11, issue 677's note 12). A bind makes no shared value, and nor does a rotation. +func TestUnstampedPairRowsAreNeverMadeOneSharedValue(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade) + movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade) + + for i := 0; i < 2; i++ { + if o := pass(t, v, standIn); o.Outcome != PairPerPair { + t.Fatalf("unstamped rows, pass %d: %+v", i, o) + } + } + mirror["novox.third"] = handedWith("novox", "third", "stand-in-api@anchor/stand-in") + if o := pass(t, v, standIn); o.Outcome != PairPerPair { + t.Fatalf("a bind to a provider with logins of their own: %+v", o) + } + if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "its ordinary rotation"); err != nil { + t.Fatal(err) + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatal("a shared value was made for a provider whose consumers keep logins of their own") + } + if changedSaid(t, v, standIn, "") != 0 { + t.Fatal("secret-changed was said for a shared value") + } +} + +// Two consumers of postgres-database (a login each, ADR 0048) hold different values after a rotation, each remade +// apart, and each consumer's machine is handed only its own; the provider's machine is handed both. +func TestARotationRemakesEachPostgresConsumersOwnLoginApart(t *testing.T) { + mirror := assignments{ + "novox.postgres": handedWith("novox", "postgres"), + "g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres"), + "ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres"), + } + v := pairVault(t, mirror) + gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami" + for name, value := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} { + if _, err := v.keepWith(name, value, "node.novox", "", OriginMade, keepOptions{neverReplace: true, + movedFrom: &MovedFrom{By: "node.novox"}}); err != nil { + t.Fatal(err) + } + } + changed, err := v.Rotate(ControllerCaller, "provision/novox/postgres-database", "person.jochen", "yearly") + if err != nil || len(changed.Each) != 2 { + t.Fatalf("the rotation: %+v (%v)", changed, err) + } + ofGitea, _, err := asks(v, "provisioner.g14", gitea) + if err != nil { + t.Fatalf("gitea's machine was refused its own login: %v", err) + } + ofUmami, _, err := asks(v, "provisioner.ace", umami) + if err != nil { + t.Fatalf("umami's machine was refused its own login: %v", err) + } + if ofGitea == ofUmami || ofGitea == "login-of-gitea" || ofUmami == "login-of-umami" { + t.Fatalf("after the rotation gitea holds %q and umami %q: not two new values of their own", ofGitea, ofUmami) + } + if _, _, err := asks(v, "provisioner.g14", umami); err == nil { + t.Fatal("gitea's machine was handed umami's login") + } + if _, _, err := asks(v, "provisioner.ace", gitea); err == nil { + t.Fatal("umami's machine was handed gitea's login") + } + for name, want := range map[string]string{gitea: ofGitea, umami: ofUmami} { + if got, _, err := asks(v, "provisioner.novox", name); err != nil || got != want { + t.Fatalf("the provider's machine got %q for %s (%v)", got, name, err) + } + } + if _, held, _ := v.Store.Read("provision/novox/postgres-database"); held { + t.Fatal("a shared value was made for postgres-database") + } +} + +// A provider and provision the vault holds no row of: whether its offer shares one credential or keeps a login per +// consumer is not said where the vault reads, so it makes nothing rather than guess. +func TestAProviderTheVaultHoldsNoRowOfIsNotGuessed(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "no plan says whether") { + t.Fatalf("a provider with no row: %+v", o) + } + if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil { + t.Fatal("a rotation guessed the form of a provider with no row") + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatal("a value was made for a provider whose form the vault does not know") + } +} + +// The vault makes no value a holder would never get (the lead's rule on issue 677): a provider not handed to the +// provisioner, or a consumer the move brought in that is still filled the old way, holds every make and remake +// back, and a rotation too. +func TestNoValueIsMadeThatAHolderFilledTheOldWayWouldNeverGet(t *testing.T) { + t.Run("an unmoved provider", func(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": unmoved("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "stand-in on anchor") { + t.Fatalf("an unmoved provider: %+v", o) + } + if _, held, _ := v.Store.Read(standIn); held { + t.Fatal("a value was made that the provider would never get") + } + if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil { + t.Fatal("a rotation made a value the provider would never get") + } + }) + t.Run("an unmoved consumer", func(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + "ace.old": unmoved("ace", "old"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade) + movedRow(t, v, "ace", "old", "shared", "stamp-1", OriginMade) + if o := pass(t, v, standIn); o.Outcome != PairAdopted { + t.Fatalf("adopting changes no value, so it is not held back: %+v", o) + } + mirror["novox.new"] = handedWith("novox", "new", "stand-in-api@anchor/stand-in") + o := pass(t, v, standIn) + if o.Outcome != PairHeld || !strings.Contains(o.Why, "old on ace") { + t.Fatalf("a bind while a consumer is filled the old way: %+v", o) + } + if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "shared" || gen != 1 { + t.Fatal("the value was remade while a consumer filled the old way holds it") + } + if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil || + !strings.Contains(err.Error(), "old on ace") { + t.Fatalf("a rotation while a consumer is filled the old way: %v", err) + } + // Once it moves, the remake the bind asked for follows. + mirror["ace.old"] = handedWith("ace", "old", "stand-in-api@anchor/stand-in") + if o := pass(t, v, standIn); o.Outcome != PairRemade { + t.Fatalf("after the consumer moved: %+v", o) + } + }) +} + +// A pass that cannot read every assignment makes nothing: a bind it missed would read as an unbind. +func TestAPassThatCannotReadTheAssignmentsMakesNothing(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade) + pass(t, v, standIn) + mirror["g14.broken"] = "not json" + if _, err := v.PairPass(); err == nil { + t.Fatal("a pass over an unreadable assignment said nothing") + } + v.Assignments.Keys = func() ([]string, error) { return nil, errors.New("the bus is away") } + if _, err := v.PairPass(); err == nil { + t.Fatal("a pass that could not list the assignments said nothing") + } + if _, gen, _ := asks(v, "provisioner.g14", standIn); gen != 1 { + t.Fatal("a pass that could not read the assignments remade the value") + } +} + +// A module only pulled in by another has a value of its own in the mirror (issue 765), naming what requires it: its +// plan binds like an assigned module's. +func TestAPulledInConsumerBindsLikeAnAssignedOne(t *testing.T) { + mirror := assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + } + v := pairVault(t, mirror) + movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade) + pass(t, v, standIn) + mirror["ace.helper"] = strings.Replace(handedWith("ace", "helper", "stand-in-api@anchor/stand-in"), + `"module":"helper",`, `"module":"helper","required-by":"desk",`, 1) + if o := pass(t, v, standIn); o.Outcome != PairRemade { + t.Fatalf("a pulled-in consumer binding: %+v", o) + } + if _, _, err := asks(v, "provisioner.ace", standIn); err != nil { + t.Fatalf("the pulled-in consumer's machine was refused: %v", err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/perholder.go b/modules/mesh-vault/cmd/mesh-vault/perholder.go new file mode 100644 index 00000000..fc9eb88d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/perholder.go @@ -0,0 +1,201 @@ +package main + +// A value a provider's software issued for each holder apart (design 57's V2c, issue 749; ADR 0349 clauses 10 and +// 11). The forge mints one read token per agent account (ADR 0365 clause 4): one value per provision, sealed to +// every holder alike, cannot carry that. So an issuing provider's tool may answer one value per holder — a holder +// being a consuming module on one machine — and the vault keeps each as that holder's own and hands it to that +// holder alone. A tool that answers one value keeps the provision's one credential, as before. +// +// Names in the store: `provision////`. + +import ( + "errors" + "fmt" + "strings" +) + +// Holder is who one issued value is for: a consuming module on one machine. +type Holder struct { + Node string `json:"node"` + Module string `json:"module"` +} + +func (h Holder) String() string { return h.Module + " on " + h.Node } + +// HolderSecret is the vault's name for the value a provider's software issued for one holder of a provision. +func HolderSecret(provider, provision string, h Holder) (string, error) { + base, err := ProvisionSecret(provider, provision) + if err != nil { + return "", err + } + if err := h.valid(); err != nil { + return "", err + } + return base + "/" + h.Node + "/" + h.Module, nil +} + +func (h Holder) valid() error { + if !namePart.MatchString(h.Node) { + return fmt.Errorf("the holder's machine %q is not a name the mesh writes", h.Node) + } + if !namePart.MatchString(h.Module) { + return fmt.Errorf("the holder's module %q is not a name the mesh writes", h.Module) + } + return nil +} + +// holderOf is the holder a secret's name is for, when it is one holder's issued value. +func holderOf(name string) (Holder, bool) { + parts := strings.Split(name, "/") + if len(parts) != 5 || parts[0] != "provision" { + return Holder{}, false + } + return Holder{Node: parts[3], Module: parts[4]}, true +} + +// isTheHolder is whether caller, the bus user the runtime proved, is the holder: its machine's provisioner client +// (`provisioner.`, an account of its own, mesh-controller internal/broker) or the module itself under an +// account of its own (`.`, ADR 0259 §8's runs-as). Any other caller is not, whatever else allows it. +func isTheHolder(caller string, h Holder) bool { + return caller == "provisioner."+h.Node || caller == h.Node+"."+h.Module +} + +// notTheHolder says why caller may not have a holder's value, or "". +func notTheHolder(caller, name string) string { + h, per := holderOf(name) + if !per || isTheHolder(caller, h) { + return "" + } + return fmt.Sprintf("%s was issued for %s alone, and is handed only to that holder: provisioner.%s or %s.%s", + name, h, h.Node, h.Node, h.Module) +} + +// checkHolders refuses a list of holders that is not names the mesh writes, each once. +func checkHolders(holders []Holder) error { + seen := map[Holder]bool{} + for _, h := range holders { + if err := h.valid(); err != nil { + return err + } + if seen[h] { + return fmt.Errorf("%s is named twice", h) + } + seen[h] = true + } + return nil +} + +// IssuedHolder is what the vault kept for one holder. Never the value. +type IssuedHolder struct { + Holder + Secret string `json:"secret"` + Generation int `json:"generation"` + Unchanged bool `json:"unchanged,omitempty"` +} + +// sealedFor is one holder's value as the tool answers it: sealed to the call's key. +type sealedFor struct { + Node string `json:"node"` + Module string `json:"module"` + Sealed string `json:"sealed"` +} + +// takePerHolder opens every holder's value the tool sealed to the call's key and keeps each as that holder's own. +// It takes the answer whole or not at all: a holder not asked about, a holder twice, a value not sealed to the call +// or an empty one keeps nothing for anybody. A holder asked about and not answered is said, and keeps nothing. +func (v *Vault) takePerHolder(caller, call, provider, provision string, asked []Holder, answered []sealedFor) (Issued, error) { + c, err := v.takeCall("issued", call) + if err != nil { + return Issued{}, err + } + defer func() { c.private = [32]byte{} }() + if len(asked) == 0 { + return Issued{}, fmt.Errorf("%s answered a value per holder, and no holder was named to it; nothing was taken", c.by) + } + want := map[Holder]bool{} + for _, h := range asked { + want[h] = true + } + type opened struct { + holder Holder + secret string + value string + } + var all []opened + got := map[Holder]bool{} + for _, a := range answered { + h := Holder{Node: a.Node, Module: a.Module} + switch { + case !want[h]: + return Issued{}, fmt.Errorf("%s answered a value for %s, which it was not asked about; nothing was taken", c.by, h) + case got[h]: + return Issued{}, fmt.Errorf("%s answered %s twice; nothing was taken", c.by, h) + } + got[h] = true + raw, err := c.openOne(a.Sealed) + if err != nil { + return Issued{}, fmt.Errorf("%s's value: %w", h, err) + } + value := strings.TrimSpace(string(raw)) + zero(raw) + if value == "" { + return Issued{}, fmt.Errorf("%s answered an empty value for %s; nothing was taken", c.by, h) + } + secret, err := HolderSecret(provider, provision, h) + if err != nil { + return Issued{}, err + } + all = append(all, opened{holder: h, secret: secret, value: value}) + } + base, _ := ProvisionSecret(provider, provision) + out := Issued{Secret: base, Unchanged: true} + var failed []error + for i := range all { + changed, err := v.keep(all[i].secret, all[i].value, c.by, caller, OriginIssued) + all[i].value = "" + if err != nil { + failed = append(failed, fmt.Errorf("%s: %w", all[i].holder, err)) + continue + } + out.Holders = append(out.Holders, IssuedHolder{Holder: all[i].holder, Secret: changed.Name, + Generation: changed.Generation, Unchanged: changed.Unchanged}) + if !changed.Unchanged { + out.Unchanged = false + } + } + for _, h := range asked { + if !got[h] { + out.NotAnswered = append(out.NotAnswered, h) + } + } + if len(failed) > 0 { + return out, fmt.Errorf("not every holder's value was kept: %w", errors.Join(failed...)) + } + return out, nil +} + +// holdersArg reads the holders the controller names, refusing anything else. +func holdersArg(a map[string]any) ([]Holder, error) { + raw, ok := a["holders"] + if !ok || raw == nil { + return nil, nil + } + list, ok := raw.([]any) + if !ok { + return nil, errors.New("holders is a list of {node, module}") + } + out := make([]Holder, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + return nil, errors.New("holders is a list of {node, module}") + } + node, nok := m["node"].(string) + module, mok := m["module"].(string) + if !nok || !mok || len(m) != 2 { + return nil, errors.New("each holder is {node, module} and nothing else") + } + out = append(out, Holder{Node: strings.TrimSpace(node), Module: strings.TrimSpace(module)}) + } + return out, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/perholder_bus_test.go b/modules/mesh-vault/cmd/mesh-vault/perholder_bus_test.go new file mode 100644 index 00000000..36fca76d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/perholder_bus_test.go @@ -0,0 +1,284 @@ +package main + +// Issue 749's proof on a real bus: a NATS server whose users are the accounts of the mesh's parts (the vault's own, +// the provider's machine's runtime, each machine's provisioner client), each allowed only its own subjects, so the +// caller a tool sees is the one the bus proved — as the mesh's runtime does (subjects ending in the caller's token, +// which only that caller may publish; mesh-controller internal/broker). A listener on every subject keeps every byte +// that crosses. + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +// callerToken is a caller's name as the last token of a subject. +func callerToken(user string) string { return strings.ReplaceAll(user, ".", "_") } + +func untoken(token string) string { return strings.ReplaceAll(token, "_", ".") } + +const busConf = `listen: 127.0.0.1:-1 +authorization { + users = [ + { user: "novox.mesh-vault", password: "pw", permissions: { + publish: { allow: ["mesh.mod.gitea.call.gitea_agent_token.novox.novox_mesh-vault"] } + subscribe: { allow: ["mesh.seat.mesh-vault.call.get.*", "_INBOX.novox.mesh-vault.>"] } + allow_responses: true } } + { user: "novox.node-tools", password: "pw", permissions: { + publish: { deny: [">"] } + subscribe: { allow: ["mesh.mod.gitea.call.gitea_agent_token.novox.*"] } + allow_responses: true } } + { user: "provisioner.anchor", password: "pw", permissions: { + publish: { allow: ["mesh.seat.mesh-vault.call.get.provisioner_anchor"] } + subscribe: { allow: ["_INBOX.provisioner.anchor.>"] } } } + { user: "provisioner.laptop", password: "pw", permissions: { + publish: { allow: ["mesh.seat.mesh-vault.call.get.provisioner_laptop"] } + subscribe: { allow: ["_INBOX.provisioner.laptop.>"] } } } + { user: "tap", password: "pw", permissions: { publish: { deny: [">"] }, subscribe: { allow: [">"] } } } + ] +} +` + +type busAnswer struct { + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func connectAs(t *testing.T, url, user string) *nats.Conn { + t.Helper() + conn, err := nats.Connect(url, nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user), + nats.ErrorHandler(func(*nats.Conn, *nats.Subscription, error) {})) + if err != nil { + t.Fatalf("%s cannot connect: %v", user, err) + } + t.Cleanup(conn.Close) + return conn +} + +// forgeOnABus is the forge's read tokens issued into a vault keeping rule, on a real bus: what a machine's client is +// answered when it asks over the bus with a key of its own (opened), and every byte that crossed. +func forgeOnABus(t *testing.T, rule Askers) (ask func(user, name string) (string, error), heard func() string) { + t.Helper() + conf := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(conf, []byte(busConf), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatal(err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + url := s.ClientURL() + + // Every byte that crosses. + var mu sync.Mutex + var crossed []string + tap := connectAs(t, url, "tap") + if _, err := tap.Subscribe(">", func(m *nats.Msg) { + mu.Lock() + crossed = append(crossed, m.Subject+" "+string(m.Data)) + mu.Unlock() + }); err != nil { + t.Fatal(err) + } + _ = tap.Flush() + + // The forge's machine's runtime serves the stand-in issuing tool, naming the caller the subject proves. + tool := perHolderProvider(agentTokens, nil) + runtime := connectAs(t, url, "novox.node-tools") + if _, err := runtime.Subscribe("mesh.mod.gitea.call.gitea_agent_token.novox.*", func(m *nats.Msg) { + parts := strings.Split(m.Subject, ".") + var args map[string]any + _ = json.Unmarshal(m.Data, &args) + out, err := tool(untoken(parts[len(parts)-1]), args) + answer := busAnswer{} + if err != nil { + answer.Error = err.Error() + } else { + answer.Result, _ = json.Marshal(out) + } + raw, _ := json.Marshal(answer) + _ = m.Respond(raw) + }); err != nil { + t.Fatal(err) + } + _ = runtime.Flush() + + // The vault, on its own account: it asks the tool over the bus in its own name, and answers `get` to the caller + // the subject proves. Its rule for who may ask would let both clients ask for both values: its rule for a + // holder's value is what keeps each to its own. + v := newVault(t, rule) + vaultConn := connectAs(t, url, "novox.mesh-vault") + v.Ask = func(key string, body any) (json.RawMessage, error) { + call, node, _ := strings.Cut(key, "@") + module, tl, _ := strings.Cut(call, ".") + raw, _ := json.Marshal(body) + msg, err := vaultConn.Request(fmt.Sprintf("mesh.mod.%s.call.%s.%s.%s", module, tl, node, + callerToken("novox.mesh-vault")), raw, 5*time.Second) + if err != nil { + return nil, err + } + var a busAnswer + if err := json.Unmarshal(msg.Data, &a); err != nil { + return nil, err + } + if a.Error != "" { + return nil, fmt.Errorf("%s", a.Error) + } + return a.Result, nil + } + if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) { + parts := strings.Split(m.Subject, ".") + var args struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` + } + _ = json.Unmarshal(m.Data, &args) + answer := busAnswer{} + got, err := v.Get(untoken(parts[len(parts)-1]), args.Name, args.PublicKey) + if err != nil { + answer.Error = err.Error() + } else { + answer.Result, _ = json.Marshal(got) + } + raw, _ := json.Marshal(answer) + _ = m.Respond(raw) + }); err != nil { + t.Fatal(err) + } + _ = vaultConn.Flush() + + // The controller's `secret issued` for the forge's read tokens, naming both holders. + issued, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents) + if err != nil { + t.Fatal(err) + } + if len(issued.Holders) != 2 { + t.Fatalf("kept %+v", issued) + } + + // Each machine's client asks over the bus, with a key of its own. + ask = func(user, name string) (string, error) { + conn := connectAs(t, url, user) + key, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]string{"name": name, "public_key": key.PublicKey}) + msg, err := conn.Request("mesh.seat.mesh-vault.call.get."+callerToken(user), raw, 3*time.Second) + if err != nil { + return "", err + } + var a busAnswer + if err := json.Unmarshal(msg.Data, &a); err != nil { + t.Fatal(err) + } + if a.Error != "" { + return "", fmt.Errorf("%s", a.Error) + } + var got Answer + if err := json.Unmarshal(a.Result, &got); err != nil { + t.Fatal(err) + } + return seal.Open(got.Sealed, key.PrivateKey) + } + // No client can ask in another's name: the bus refuses laptop's client anchor's subject. + laptop := connectAs(t, url, "provisioner.laptop") + key, _ := seal.GenerateKeyPair() + raw, _ := json.Marshal(map[string]string{"name": anchorsToken, "public_key": key.PublicKey}) + if _, err := laptop.Request("mesh.seat.mesh-vault.call.get."+callerToken("provisioner.anchor"), raw, + time.Second); err == nil { + t.Error("laptop's client asked in anchor's name and was answered") + } + heard = func() string { + _ = tap.Flush() + time.Sleep(100 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + return strings.Join(crossed, "\n") + } + return ask, heard +} + +// noneInTheClear fails when an issued value, or its base64, crossed the bus. +func noneInTheClear(t *testing.T, all string) { + t.Helper() + if !strings.Contains(all, "mesh.mod.gitea.call.gitea_agent_token.novox.novox_mesh-vault") || + !strings.Contains(all, "mesh.seat.mesh-vault.call.get.provisioner_anchor") { + t.Fatalf("the listener heard nothing of the calls:\n%s", all) + } + for _, token := range agentTokens { + for _, form := range []string{token, base64.StdEncoding.EncodeToString([]byte(token)), + base64.RawURLEncoding.EncodeToString([]byte(token))} { + if strings.Contains(all, form) { + t.Fatalf("an issued value crossed the bus in the clear") + } + } + } +} + +func TestOnARealBusTwoHoldersOnTwoMachinesEachGetOnlyTheirOwnIssuedValueAndNoneCrossesInTheClear(t *testing.T) { + ask, heard := forgeOnABus(t, bothMayAsk) + if got, err := ask("provisioner.anchor", anchorsToken); err != nil || got != agentTokens["anchor/claude-code"] { + t.Errorf("anchor's client got %q, %v", got, err) + } + if got, err := ask("provisioner.laptop", laptopsToken); err != nil || got != agentTokens["laptop/claude-code"] { + t.Errorf("laptop's client got %q, %v", got, err) + } + if got, err := ask("provisioner.anchor", laptopsToken); err == nil || !strings.Contains(err.Error(), "alone") { + t.Errorf("anchor's client was handed laptop's value %q: %v", got, err) + } + if got, err := ask("provisioner.laptop", anchorsToken); err == nil || !strings.Contains(err.Error(), "alone") { + t.Errorf("laptop's client was handed anchor's value %q: %v", got, err) + } + noneInTheClear(t, heard()) +} + +// Issue 757's proof on a real bus, with the rule the running vault keeps: a machine's client is handed the value the +// forge issued for its claude-code because the module's plan there names gitea-read from the forge's machine, and +// is refused another machine's and another module's, sealed to its key and never in the clear. +func TestOnARealBusTheRunningRuleHandsAClientTheIssuedValueItsModulesPlanNamesAndNoOther(t *testing.T) { + plan := `{"templates":{"source":"x","digest":"y"},"files":[],"provisions":[{"name":"gitea-read",` + + `"provision":"gitea-read","provider":"novox","path":"/var/lib/claude-code/gitea-read.secret"}]}` + mirror := assignments{ + "anchor.claude-code": `{"node":"anchor","module":"claude-code","provisioning":` + plan + `}`, + "laptop.claude-code": `{"node":"laptop","module":"claude-code","provisioning":` + plan + `}`, + "anchor.zsh": `{"node":"anchor","module":"zsh","provisioning":{"secrets":[{"name":"token"}]}}`, + } + ask, heard := forgeOnABus(t, Declared{Read: mirror.read}) + if got, err := ask("provisioner.anchor", anchorsToken); err != nil || got != agentTokens["anchor/claude-code"] { + t.Errorf("anchor's client got %q, %v", got, err) + } + if got, err := ask("provisioner.laptop", laptopsToken); err != nil || got != agentTokens["laptop/claude-code"] { + t.Errorf("laptop's client got %q, %v", got, err) + } + for _, r := range []struct{ user, name, why string }{ + {"provisioner.anchor", laptopsToken, "another machine's holder value"}, + {"provisioner.laptop", anchorsToken, "another machine's holder value"}, + {"provisioner.anchor", "provision/novox/gitea-read/anchor/zsh", "another module's, its plan naming it not"}, + } { + if got, err := ask(r.user, r.name); err == nil { + t.Errorf("%s was handed %s, %s: %q", r.user, r.name, r.why, got) + } + } + noneInTheClear(t, heard()) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/perholder_test.go b/modules/mesh-vault/cmd/mesh-vault/perholder_test.go new file mode 100644 index 00000000..440e8752 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/perholder_test.go @@ -0,0 +1,291 @@ +package main + +// A value a provider's software issued for each holder apart (design 57's V2c, issue 749; ADR 0349 clauses 10 and +// 11; ADR 0365 clause 4, where the forge mints one read token per agent account): the vault asks the provider's tool +// naming the holders, keeps each value the tool answers as that holder's own, and hands it, sealed, to that holder +// alone. A provider that answers one value keeps the provision's one credential, as before. + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/nacl/box" +) + +// The two agents' tokens the stand-in forge mints: one per holder, each different. +var agentTokens = map[string]string{ + "anchor/claude-code": "gta_anchor-agent-read-token-0001", + "laptop/claude-code": "gta_laptop-agent-read-token-0002", +} + +// perHolderProvider is a provider's tool that issues one value per holder (the forge's read token per agent +// account): it answers the vault's account alone, each asked holder's value sealed to the call's key. +func perHolderProvider(tokens map[string]string, asked *[]map[string]any) func(string, map[string]any) (map[string]any, error) { + return func(caller string, args map[string]any) (map[string]any, error) { + if asked != nil { + *asked = append(*asked, args) + } + if !strings.HasSuffix(caller, ".mesh-vault") { + return nil, errors.New("this tool answers the vault alone") + } + key, _ := args["seal_to"].(string) + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return nil, errors.New("seal_to is not a sealing key") + } + var to [32]byte + copy(to[:], raw) + list, _ := args["holders"].([]any) + var out []map[string]any + for _, h := range list { + m, _ := h.(map[string]any) + node, _ := m["node"].(string) + module, _ := m["module"].(string) + value, ok := tokens[node+"/"+module] + if !ok { + continue + } + sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader) + if err != nil { + return nil, err + } + out = append(out, map[string]any{"node": node, "module": module, + "sealed": base64.StdEncoding.EncodeToString(sealed)}) + } + return map[string]any{"holders": out}, nil + } +} + +var twoAgents = []Holder{{Node: "anchor", Module: "claude-code"}, {Node: "laptop", Module: "claude-code"}} + +// The holders' secrets' names in the vault's store. +const ( + anchorsToken = "provision/novox/gitea-read/anchor/claude-code" + laptopsToken = "provision/novox/gitea-read/laptop/claude-code" +) + +// bothMayAsk is a stand-in rule that would let both machines' clients ask for both values: the vault's own rule +// for a holder's value is what keeps each to its own. +var bothMayAsk = AllowList{ + "provisioner.anchor": {anchorsToken, laptopsToken}, + "provisioner.laptop": {anchorsToken, laptopsToken}, + "laptop.claude-code": {anchorsToken, laptopsToken}, +} + +func forgeMesh(provider func(string, map[string]any) (map[string]any, error)) *aMesh { + return &aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){ + "gitea.gitea_agent_token@novox": provider}} +} + +// Two holders on two machines: the vault asks the tool naming both, keeps a value for each, and hands each only to +// that holder (its machine's provisioner client, or the module under an account of its own), sealed to its key. +func TestAProviderIssuesAValueForEachHolderAndEachHolderGetsOnlyItsOwn(t *testing.T) { + v := newVault(t, bothMayAsk) + var asked []map[string]any + mesh := forgeMesh(perHolderProvider(agentTokens, &asked)) + v.Ask = mesh.ask + got, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents) + if err != nil { + t.Fatal(err) + } + if len(asked) != 1 || fmt.Sprint(asked[0]["holders"]) != "[map[module:claude-code node:anchor] map[module:claude-code node:laptop]]" { + t.Fatalf("the tool was asked %v", asked) + } + if len(got.Holders) != 2 || got.Holders[0].Secret != anchorsToken || got.Holders[1].Secret != laptopsToken || + got.Holders[0].Generation != 1 || got.Unchanged { + t.Fatalf("answered %+v", got) + } + if _, held, _ := v.Store.Read("provision/novox/gitea-read"); held { + t.Error("a value per holder was also kept as the provision's one credential") + } + // Each holder gets its own, by its machine's client and by itself under an account of its own. + if got := handedTo(t, v, "provisioner.anchor", anchorsToken); got != agentTokens["anchor/claude-code"] { + t.Errorf("anchor got %q", got) + } + if got := handedTo(t, v, "provisioner.laptop", laptopsToken); got != agentTokens["laptop/claude-code"] { + t.Errorf("laptop got %q", got) + } + if got := handedTo(t, v, "laptop.claude-code", laptopsToken); got != agentTokens["laptop/claude-code"] { + t.Errorf("laptop's agent got %q", got) + } + // Neither gets the other's, though the stand-in rule would allow it. + for caller, name := range map[string]string{"provisioner.laptop": anchorsToken, "provisioner.anchor": laptopsToken, + "laptop.claude-code": anchorsToken} { + if _, err := v.Get(caller, name, mustKey(t)); err == nil || !strings.Contains(err.Error(), "alone") { + t.Errorf("%s was handed %s: %v", caller, name, err) + } + } + handed, _ := v.Store.Handed(20) + refused := 0 + for _, h := range handed { + if h.Outcome == "refused" { + refused++ + } + } + if refused != 3 { + t.Errorf("%d refusals recorded, want 3: %+v", refused, handed) + } + for _, token := range agentTokens { + if strings.Contains(mesh.said(), token) || strings.Contains(everything(t, v, got, handed), token) { + t.Fatal("an issued value crossed the bus, or is in something the vault said or wrote, in the clear") + } + } + rec, _, _ := v.Store.Read(anchorsToken) + if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller || rec.Operator.Sealed == "" { + t.Errorf("the record: %+v", rec) + } + + // The same values again write nothing; one holder's new value changes that holder alone (ADR 0305 §5). + again, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents) + if err != nil || !again.Unchanged || again.Holders[0].Generation != 1 || again.Holders[1].Generation != 1 { + t.Errorf("again: %+v %v", again, err) + } + renewed := map[string]string{"anchor/claude-code": agentTokens["anchor/claude-code"], + "laptop/claude-code": "gta_laptop-agent-read-token-0003"} + v.Ask = forgeMesh(perHolderProvider(renewed, nil)).ask + moved, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents) + if err != nil || moved.Unchanged || !moved.Holders[0].Unchanged || moved.Holders[1].Unchanged || + moved.Holders[1].Generation != 2 { + t.Errorf("renewed: %+v %v", moved, err) + } + // A holder's value is issued by the software: never rotated, never accepted. + if _, err := v.Rotate(ControllerCaller, laptopsToken, "jochen", "test"); err == nil { + t.Error("a holder's issued value was rotated") + } + if _, err := v.AcceptOpen(ControllerCaller, laptopsToken, "jochen", ""); err == nil { + t.Error("a holder's issued value was opened for accept") + } +} + +// A holder the tool was not asked about, a holder twice, a value not sealed to the call, or an answer of both +// shapes: nothing is kept, for any holder. A holder the tool did not answer is said, not kept. +func TestAPerHolderAnswerIsTakenWholeOrNotAtAll(t *testing.T) { + sealedTo := func(args map[string]any, value string) string { + raw, _ := base64.StdEncoding.DecodeString(args["seal_to"].(string)) + var to [32]byte + copy(to[:], raw) + s, _ := box.SealAnonymous(nil, []byte(value), &to, rand.Reader) + return base64.StdEncoding.EncodeToString(s) + } + var stranger [32]byte + _, _ = rand.Read(stranger[:]) + notToTheCall, _ := box.SealAnonymous(nil, []byte("forged"), &stranger, rand.Reader) + answers := map[string]func(args map[string]any) map[string]any{ + "a holder not asked about": func(args map[string]any) map[string]any { + return map[string]any{"holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}, + map[string]any{"node": "ace", "module": "claude-code", "sealed": sealedTo(args, "b")}}} + }, + "a holder twice": func(args map[string]any) map[string]any { + return map[string]any{"holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}, + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "b")}}} + }, + "a value not sealed to the call": func(args map[string]any) map[string]any { + return map[string]any{"holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}, + map[string]any{"node": "laptop", "module": "claude-code", + "sealed": base64.StdEncoding.EncodeToString(notToTheCall)}}} + }, + "both shapes": func(args map[string]any) map[string]any { + return map[string]any{"sealed": sealedTo(args, "one"), "holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}}} + }, + "an empty value": func(args map[string]any) map[string]any { + return map[string]any{"holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, " ")}}} + }, + } + for what, answer := range answers { + v := newVault(t, nil) + answer := answer + v.Ask = forgeMesh(func(_ string, args map[string]any) (map[string]any, error) { return answer(args), nil }).ask + if _, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents); err == nil { + t.Errorf("%s was taken", what) + } + if all, _ := v.Store.List(); len(all) != 0 { + t.Errorf("%s kept %d values", what, len(all)) + } + if len(v.pending) != 0 { + t.Errorf("%s left a call's key kept", what) + } + } + // Values per holder when the controller named no holder: refused, nothing kept. + v := newVault(t, nil) + v.Ask = forgeMesh(func(_ string, args map[string]any) (map[string]any, error) { + return map[string]any{"holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}}}, nil + }).ask + if _, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", nil); err == nil { + t.Error("values per holder were taken for no holder named") + } + // A holder the tool did not answer is said and keeps nothing; the others are kept. + w := newVault(t, nil) + w.Ask = forgeMesh(perHolderProvider(map[string]string{"anchor/claude-code": "only-anchors"}, nil)).ask + got, err := w.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents) + if err != nil || len(got.Holders) != 1 || len(got.NotAnswered) != 1 || got.NotAnswered[0] != twoAgents[1] { + t.Errorf("a holder not answered: %+v %v", got, err) + } + if _, held, _ := w.Store.Read(laptopsToken); held { + t.Error("a holder the tool did not answer holds a value") + } + // The holders the controller names are names the mesh writes, each once. + for _, bad := range [][]Holder{{{Node: "anchor", Module: ""}}, {{Node: "../x", Module: "claude-code"}}, + {twoAgents[0], twoAgents[0]}} { + if _, err := w.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", bad); err == nil { + t.Errorf("holders %v were asked for", bad) + } + } +} + +// A provider that answers one value keeps the provision's one credential, though the controller named holders: +// the per-provision path stays as it is. +func TestAProviderAnsweringOneValueKeepsTheProvisionsOneCredential(t *testing.T) { + v := newVault(t, AllowList{"provisioner.anchor": {"provision/anchor/plex-api"}}) + software := "plex-token-from-the-vendor" + v.Ask = (&aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){ + "plex.plex_issued_credential@anchor": standInProvider(&software)}}).ask + got, err := v.TakeIssuedFor(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential", + []Holder{{Node: "anchor", Module: "kometa"}, {Node: "anchor", Module: "tautulli"}}) + if err != nil || got.Secret != "provision/anchor/plex-api" || got.Generation != 1 || len(got.Holders) != 0 { + t.Fatalf("%+v %v", got, err) + } + if handedTo(t, v, "provisioner.anchor", "provision/anchor/plex-api") != software { + t.Error("the provision's one credential is not what the software issued") + } +} + +// The seat's verb takes the holders as the controller sends them, and refuses a list it cannot read. +func TestTheIssuedVerbTakesTheHolders(t *testing.T) { + v := newVault(t, nil) + v.Ask = forgeMesh(perHolderProvider(agentTokens, nil)).ask + defs := definitions(&Env{Vault: v.Vault}) + issued := toolNamed(t, defs, Seat+".issued") + answer, err := issued.runAs(ControllerCaller, map[string]any{"provision": "gitea-read", "provider": "novox", + "module": "gitea", "tool": "gitea_agent_token", "holders": []any{ + map[string]any{"node": "anchor", "module": "claude-code"}, + map[string]any{"node": "laptop", "module": "claude-code"}}}) + if err != nil || len(answer.(Issued).Holders) != 2 { + t.Fatalf("%+v %v", answer, err) + } + for _, bad := range []any{"anchor", []any{"anchor"}, []any{map[string]any{"node": 1}}} { + if _, err := issued.runAs(ControllerCaller, map[string]any{"provision": "gitea-read", "provider": "novox", + "module": "gitea", "tool": "gitea_agent_token", "holders": bad}); err == nil { + t.Errorf("holders %v were read", bad) + } + } +} + +func mustKey(t *testing.T) string { + t.Helper() + key, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + return key.PublicKey +} diff --git a/modules/mesh-vault/cmd/mesh-vault/providers_test.go b/modules/mesh-vault/cmd/mesh-vault/providers_test.go new file mode 100644 index 00000000..296c13c3 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/providers_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +// A plan names, for each provision its module requires, the provider serving it (issue 760); only an entry with a +// name is a credential the machine's client fills (issue 757). The rule hands the value issued for the holder only +// for such an entry: naming a provider alone widens nothing. +func TestARuleHandsOnlyACredentialThePlanNamesNotAProviderAlone(t *testing.T) { + plan := `{"templates":{"source":"x","digest":"y"},"files":[],"provisions":[` + + `{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` + + `{"name":"gitea-read","provision":"gitea-read","provider":"novox","provider-module":"gitea",` + + `"path":"/s/gitea-read.secret"}]}` + mirror := assignments{"anchor.app": `{"node":"anchor","module":"app","provisioning":` + plan + `}`} + rule := Declared{Read: mirror.read} + if ok, why := rule.May("provisioner.anchor", "provision/novox/gitea-read/anchor/app"); !ok { + t.Fatalf("the credential the plan names was refused: %s", why) + } + if ok, _ := rule.May("provisioner.anchor", "provision/novox/postgres-database/anchor/app"); ok { + t.Fatal("a provision the plan names only with its provider was handed out") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pulled_in_test.go b/modules/mesh-vault/cmd/mesh-vault/pulled_in_test.go new file mode 100644 index 00000000..dade00d6 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/pulled_in_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// A module only pulled in by another on a node has a value of its own in the assignments mirror (mesh-controller +// internal/inventory AssignmentValues, issue 765): its plan, what requires it, and no time it was assigned. The +// running rule hands that node's client what the plan names, as for an assigned module, and nothing else. +func TestTheRunningVaultAnswersAMachinesClientForWhatAPulledInModulesPlanNames(t *testing.T) { + mirror := assignments{ + "g14.app": `{"node":"g14","module":"app","required-by":"desk","provisioning":` + + `{"templates":{"source":"x","digest":"y"},"files":[],"secrets":[{"name":"token"}],` + + `"provisions":[{"name":"gitea-read","provision":"gitea-read","provider":"novox"}]}}`, + "g14.kept": `{"node":"g14","module":"kept","required-by":"desk"}`, + } + v := newVault(t, Declared{Read: mirror.read}) + for _, name := range []string{"own/g14/app/token", "own/g14/app/other", "own/g14/kept/token", + "provision/novox/gitea-read/g14/app"} { + if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil { + t.Fatal(err) + } + } + asker, _ := seal.GenerateKeyPair() + for _, name := range []string{"own/g14/app/token", "provision/novox/gitea-read/g14/app"} { + answer, err := v.Get("provisioner.g14", name, asker.PublicKey) + if err != nil { + t.Fatalf("the machine's client was refused %s, which its pulled-in module's plan names: %v", name, err) + } + if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-"+name { + t.Fatalf("the answer opens to %q (%v)", got, err) + } + } + for _, r := range []struct{ caller, name, why string }{ + {"provisioner.g14", "own/g14/app/other", "a secret the pulled-in module's plan does not name"}, + {"provisioner.g14", "own/g14/kept/token", "a pulled-in module not handed to the provisioner"}, + {"provisioner.ace", "own/g14/app/token", "another machine's client"}, + } { + if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil { + t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a) + } + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/reseal_health.go b/modules/mesh-vault/cmd/mesh-vault/reseal_health.go new file mode 100644 index 00000000..69dafd7e --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/reseal_health.go @@ -0,0 +1,44 @@ +package main + +// The vault's runtime health (novox/hq ADR 0366 §4): the vault re-seals its operator copies every minute +// (adoptOperatorKey), and a vault that keeps failing to is loud before secrets move into it. The node-engine +// asks vault_health on the vault's own runtime (`runtime-health` in module.json); the controller raises +// module.mesh-vault..unhealthy from it, with these words, and clears it on the first healthy answer. + +import ( + "fmt" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// FailedPassesUnhealthy is how many failed re-seal passes in a row make the vault unhealthy: one minute's +// pass can fail for a moment's reason and be finished by the next. +const FailedPassesUnhealthy = 3 + +// pass is one minute's re-seal, its outcome kept for vault_health. +func (e *Env) pass() { + err := adoptOperatorKey(e) + e.mu.Lock() + defer e.mu.Unlock() + if err == nil { + e.failedPasses, e.lastFailure = 0, "" + return + } + e.failedPasses++ + e.lastFailure = err.Error() +} + +// health is vault_health's answer. +func (e *Env) health() health.Answer { + e.mu.Lock() + defer e.mu.Unlock() + if e.failedPasses >= FailedPassesUnhealthy { + return health.Unhealthy(fmt.Sprintf("re-sealing the vault's operator copies failed on %d passes in a row "+ + "(one a minute); the last said: %s", e.failedPasses, e.lastFailure)) + } + if e.failedPasses > 0 { + return health.Healthy(fmt.Sprintf("the last %d re-seal pass(es) failed, tried again every minute; "+ + "unhealthy after %d in a row", e.failedPasses, FailedPassesUnhealthy)) + } + return health.Healthy("every re-seal pass succeeds") +} diff --git a/modules/mesh-vault/cmd/mesh-vault/reseal_health_test.go b/modules/mesh-vault/cmd/mesh-vault/reseal_health_test.go new file mode 100644 index 00000000..a93e2c71 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/reseal_health_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// novox/hq ADR 0366 §4: the vault's runtime health is a tool that says unhealthy once re-sealing has failed on +// three passes in a row, saying what failed and why, and healthy again after one pass succeeds. +func TestTheVaultIsUnhealthyAfterThreeFailedReSealPassesAndHealthyAfterOne(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { + t.Fatal(err) + } + env := &Env{Vault: v.Vault} + answer := func() health.Answer { + t.Helper() + got, err := toolNamed(t, definitions(env), "vault_health").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + a, err := health.Read(raw) + if err != nil { + t.Fatalf("the node-engine cannot read %s: %v", raw, err) + } + return a + } + if a := answer(); !a.Healthy { + t.Fatalf("a vault before any pass says %+v", a) + } + // A new operator key whose copies cannot be written: every pass fails. + pub, _ := operatorKey(t) + v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") } + _, _ = v.SetOperatorKey(asPEM(t, pub), "the test") + for pass := 1; pass <= 3; pass++ { + env.pass() + a := answer() + if pass < 3 && !a.Healthy { + t.Fatalf("after %d failed pass(es) the vault says %+v; it is unhealthy after three", pass, a) + } + if pass == 3 && (a.Healthy || a.State != health.StateUnhealthy || !strings.Contains(a.Why, "disk full") || + !strings.Contains(a.Why, "3 passes") || !strings.Contains(a.Why, "pair/postgres/gitea")) { + t.Fatalf("after three failed passes the vault says %+v", a) + } + } + // One pass that succeeds makes it healthy again, and the streak starts over. + v.Store.failWrite = nil + env.pass() + if a := answer(); !a.Healthy { + t.Fatalf("after a pass that re-sealed the vault says %+v", a) + } + v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") } + pub, _ = operatorKey(t) + _, _ = v.SetOperatorKey(asPEM(t, pub), "the test") + env.pass() + if a := answer(); !a.Healthy { + t.Fatalf("one failed pass after a good one says %+v", a) + } +} + +// A vault that could not be built re-seals nothing: its passes fail, saying why. +func TestABrokenVaultsPassesFail(t *testing.T) { + env := &Env{Broken: fmt.Errorf("the vault could not open its store: no such directory")} + for i := 0; i < 3; i++ { + env.pass() + } + got, err := toolNamed(t, definitions(env), "vault_health").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + if a := got.(health.Answer); a.Healthy || !strings.Contains(a.Why, "could not open its store") { + t.Errorf("a broken vault says %+v", a) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/rotate_one_test.go b/modules/mesh-vault/cmd/mesh-vault/rotate_one_test.go new file mode 100644 index 00000000..71b9a85d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/rotate_one_test.go @@ -0,0 +1,119 @@ +package main + +// The controller's `rotate [--consumer ] [--module ]` reaches the vault (issue 767): a +// provider's one shared value is rotated for every holder together, and it is never rotated for one consumer alone; +// a per-login provider (ADR 0158 §11) rotates the named consumer's own login and no other. + +import ( + "strings" + "testing" +) + +// postgresLogins is a vault holding a login each for gitea on g14 and umami on ace, from postgres on novox. +func postgresLogins(t *testing.T) (testVault, string, string) { + t.Helper() + v := pairVault(t, assignments{ + "novox.postgres": handedWith("novox", "postgres"), + "g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres"), + "ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres"), + }) + gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami" + for name, value := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} { + if _, err := v.keepWith(name, value, "node.novox", "", OriginMade, keepOptions{neverReplace: true, + movedFrom: &MovedFrom{By: "node.novox"}}); err != nil { + t.Fatal(err) + } + } + return v, gitea, umami +} + +func TestARotationNamingOneConsumerRemakesThatConsumersLoginAlone(t *testing.T) { + for _, one := range []Narrow{{Consumer: "g14"}, {ConsumerModule: "gitea"}, {Consumer: "g14", ConsumerModule: "gitea"}} { + v, gitea, umami := postgresLogins(t) + before := changedSaid(t, v, umami, "") + changed, err := v.RotateFor(ControllerCaller, "provision/novox/postgres-database", one, "person.jochen", + "suspected on g14") + if err != nil || len(changed.Each) != 1 || changed.Each[0].Name != gitea { + t.Fatalf("%+v: the rotation %+v (%v)", one, changed, err) + } + if got, _, err := asks(v, "provisioner.g14", gitea); err != nil || got == "login-of-gitea" { + t.Fatalf("%+v: gitea's login was not remade: %q (%v)", one, got, err) + } + if got, gen, err := asks(v, "provisioner.ace", umami); err != nil || got != "login-of-umami" || gen != 1 { + t.Fatalf("%+v: umami's login was touched: %q generation %d (%v)", one, got, gen, err) + } + if changedSaid(t, v, umami, "") != before { + t.Fatalf("%+v: secret-changed was said for umami's login", one) + } + } +} + +func TestARotationNamingAConsumerThatHoldsNoLoginChangesNothing(t *testing.T) { + v, gitea, umami := postgresLogins(t) + _, err := v.RotateFor(ControllerCaller, "provision/novox/postgres-database", Narrow{Consumer: "g14", + ConsumerModule: "umami"}, "person.jochen", "suspected") + if err == nil || !strings.Contains(err.Error(), "umami on g14") { + t.Fatalf("a consumer holding no login was not refused by name: %v", err) + } + for name, want := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} { + if got, _, _ := asks(v, "provisioner.novox", name); got != want { + t.Fatalf("%s changed on a refused rotation", name) + } + } +} + +// A shared value is one for every holder (ADR 0158): rotated, every holder gets the new one after secret-changed; it +// is never rotated for one consumer alone, which would change the value for all of them. +func TestASharedValueIsRotatedForEveryHolderAndNeverForOneConsumer(t *testing.T) { + v := pairVault(t, assignments{ + "anchor.stand-in": handedWith("anchor", "stand-in"), + "g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"), + "ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"), + }) + movedRow(t, v, "g14", "app", "the-value-they-share", "20260901T000000-1", OriginMade) + movedRow(t, v, "ace", "other", "the-value-they-share", "20260901T000000-1", OriginMade) + if o := pass(t, v, standIn); o.Outcome != PairAdopted { + t.Fatalf("stamped rows: %+v", o) + } + + if _, err := v.RotateFor(ControllerCaller, standIn, Narrow{Consumer: "g14"}, "person.jochen", "suspected"); err == nil || + !strings.Contains(err.Error(), "every holder") { + t.Fatalf("a shared value was rotated for one consumer: %v", err) + } + if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 { + t.Fatal("a refused rotation changed the shared value") + } + + changed, err := v.RotateFor(ControllerCaller, standIn, Narrow{}, "person.jochen", "yearly") + if err != nil || changed.Generation != 2 { + t.Fatalf("the rotation: %+v (%v)", changed, err) + } + var seen string + for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} { + got, gen, err := asks(v, c, standIn) + if err != nil || got == "the-value-they-share" || gen != 2 || (seen != "" && got != seen) { + t.Fatalf("%s got %q generation %d (%v): not the one new value", c, got, gen, err) + } + seen = got + } + if changedSaid(t, v, standIn, seen) == 0 { + t.Fatal("secret-changed was not said for the shared value") + } +} + +func TestARotationOfAModulesOwnSecretNamesNoConsumer(t *testing.T) { + v := newVault(t, nil) + if _, err := v.RotateFor(ControllerCaller, "own/g14/app/key", Narrow{Consumer: "g14"}, "person.jochen", "x"); err == nil { + t.Fatal("a module's own secret was rotated with a consumer named") + } +} + +func TestTheRotateToolTakesTheConsumerAndItsModule(t *testing.T) { + n, err := narrowNamed(map[string]any{"consumer": "g14", "consumer_module": "gitea"}) + if err != nil || n != (Narrow{Consumer: "g14", ConsumerModule: "gitea"}) { + t.Fatalf("%+v (%v)", n, err) + } + if _, err := narrowNamed(map[string]any{"consumer": "g14/x"}); err == nil { + t.Fatal("a consumer that is not a name was taken") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index 192c60fe..dd98a506 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -39,6 +39,15 @@ type Record struct { MadeAt time.Time `json:"made-at"` ChangedAt time.Time `json:"changed-at"` ChangedBy string `json:"changed-by"` + // ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller + // carrying a person's act (design 57's V2). Empty when they are the same. + ChangedVia string `json:"changed-via,omitempty"` + // Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at + // a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it. + Origin string `json:"origin,omitempty"` + // MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that + // handed it in and the controller's generation for it, kept as it was. + MovedFrom *MovedFrom `json:"moved-from,omitempty"` } // OperatorCopy is a value sealed to the operator key named in Key. @@ -58,6 +67,8 @@ type Store struct { handedMu sync.Mutex rolled int + // asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds. + asksMu sync.Mutex } // The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the @@ -160,6 +171,7 @@ func (s *Store) RecordHanding(h Handing) (rolled bool, err error) { if err := s.roll(path); err != nil { return false, err } + s.rolled++ rolled = true } return rolled, appendSynced(path, append(raw, '\n')) @@ -176,7 +188,6 @@ func (s *Store) roll(path string) error { if err := os.Rename(path, path+".1"); err != nil { return err } - s.rolled++ return syncDir(s.dir) } @@ -334,3 +345,99 @@ func syncDir(dir string) error { defer d.Close() return d.Sync() } + +// The two lines an ask leaves in the record: when it opened, and how it ended. +const ( + AskOpened = "opened" + AskEnded = "ended" +) + +// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's +// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended. +// Never a value. +type AskRecord struct { + ID string `json:"id"` + At time.Time `json:"at"` + Event string `json:"event"` + Secret string `json:"secret"` + Node string `json:"node,omitempty"` + Module string `json:"module,omitempty"` + Name string `json:"name,omitempty"` + Desk string `json:"desk,omitempty"` + AskedBy string `json:"asked_by,omitempty"` + Caller string `json:"caller,omitempty"` + Outcome string `json:"outcome,omitempty"` +} + +// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of +// hand-outs is, and it says whether it was. +func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) { + raw, err := json.Marshal(r) + if err != nil { + return false, err + } + path := filepath.Join(s.dir, "asks.jsonl") + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() { + if err := s.roll(path); err != nil { + return false, err + } + rolled = true + } + return rolled, appendSynced(path, append(raw, '\n')) +} + +// asks is every line of the record of asks, oldest first, the rolled files before the current one. +func (s *Store) asks() ([]AskRecord, error) { + path := filepath.Join(s.dir, "asks.jsonl") + var out []AskRecord + for i := HandedKept; i >= 0; i-- { + file := path + if i > 0 { + file = fmt.Sprintf("%s.%d", path, i) + } + raw, err := os.ReadFile(file) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var r AskRecord + if line != "" && json.Unmarshal([]byte(line), &r) == nil { + out = append(out, r) + } + } + } + return out, nil +} + +// AsksSince is every line of the record of asks at or after since, oldest first. +func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) { + all, err := s.asks() + if err != nil { + return nil, err + } + var out []AskRecord + for _, r := range all { + if !r.At.Before(since) { + out = append(out, r) + } + } + return out, nil +} + +// Asks is the latest lines of the record of asks, newest first, at most limit. +func (s *Store) Asks(limit int) ([]AskRecord, error) { + s.asksMu.Lock() + defer s.asksMu.Unlock() + all, err := s.asks() + if err != nil { + return nil, err + } + out := []AskRecord{} + for i := len(all) - 1; i >= 0 && len(out) < limit; i-- { + out = append(out, all[i]) + } + return out, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 1bd56770..a737cfdf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -16,6 +16,7 @@ import ( "path/filepath" "strings" "sync" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -33,6 +34,14 @@ type Env struct { refused string // Broken is why the vault could not be built, said by `get`. Broken error + // failedPasses is how many re-seal passes in a row failed, and lastFailure why the last one did + // (vault_health, novox/hq ADR 0366 §4). + failedPasses int + lastFailure string + // pairsSaid is what the last pass over the shared credentials said of each, and lastPairFailure why it last + // did not finish (pair.go): each said once, and again when it changes. + pairsSaid map[string]string + lastPairFailure string } func (e *Env) keyRefused() string { @@ -83,6 +92,194 @@ func definitions(env *Env) []toolDef { key, _ := a["public_key"].(string) return env.Vault.Get(caller, strings.TrimSpace(name), key) }}, + {Name: Seat + ".accept", + Description: "Take a value given at the controller's terminal (ADR 0349 clause 8), in two steps from the " + + "controller alone: name the secret (node, module and name for a module's own; provider and provision " + + "for a provision's one credential) and get a call and a key made for it; then send the call and the " + + "value sealed to that key. The vault alone opens it, and never remakes an accepted credential. With " + + "origin issued it keeps a covered bus account the controller minted, replacing it in place.", + Input: intakeInput(map[string]string{"node": "the machine, for a module's own secret", + "module": "the module, for a module's own secret", "name": "the secret's name in the module", + "provider": "the provider's machine, for a provision's credential", "provision": "the provision", + "by": "who gave it at the terminal, as the controller names them", + "origin": "issued for a bus account the controller minted for a core or moved module and hands at " + + "once (ADR 0374, issue 787), kept as that module's own secret and replaced in place on a " + + "re-issue; left out for a value given at the terminal"}), + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.AcceptTake(caller, call, arg(a, "sealed")) + } + secret, err := secretNamed(a) + if err != nil { + return nil, err + } + return v.AcceptOpen(caller, secret, arg(a, "by"), arg(a, "origin")) + }}, + {Name: Seat + ".secret-ask", + Description: "Ask the operator for a module's own secret at a desk (ADR 0349 clause 9, ADR 0277 §22–§27), " + + "asked by the controller alone: node, module, name, desk and asked_by. The ask is recorded before any " + + "prompt opens — one open ask per secret and three an hour — and the vault asks the desk's prompt itself, " + + "in its own name, with a key made for the call; what is typed opens in the vault alone. Answers how the " + + "prompt ended (given, dismissed, timed-out, empty, refused or failed) and who asked as recorded; never " + + "the value.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "node": str("the machine the secret is for"), "module": str("the module"), + "name": str("the secret's name in the module"), "desk": str("the machine whose prompt asks"), + "asked_by": str("who asked the controller, as the bus named its caller")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + return v.AskAtDesk(caller, AskOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"), + Desk: arg(a, "desk"), AskedBy: arg(a, "asked_by")}) + }}, + {Name: Seat + ".issued", + Description: "Take the credential a provider's software issued for a provision (ADR 0349 clause 10, ADR 0305), " + + "asked by the controller alone: provision, provider (its machine), module and tool. The vault asks that " + + "tool itself, in its own name, with a key made for the call, and keeps what it sealed as the provision's " + + "one credential, or, when the tool answers a value per holder named in holders, each as that holder's " + + "own, handed to it alone; the same value again writes nothing. Never answers the value.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "provision": str("the provision"), "provider": str("the provider's machine"), + "module": str("the provider's module, whose software issued it"), + "tool": str("the provider's tool that answers it, as its offer names it"), + "holders": map[string]any{"type": "array", "description": "the provision's holders from that " + + "provider, each {node, module}: the tool may answer a value for each, kept as that holder's own " + + "and handed to it alone", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "node": str("the holder's machine"), "module": str("the consuming module")}}}}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + holders, err := holdersArg(a) + if err != nil { + return nil, err + } + return v.TakeIssuedFor(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module"), arg(a, "tool"), + holders) + }}, + {Name: Seat + ".rotate", + Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " + + "and provision for a provision's credential. Refused for a value a provider's software issued, which " + + "the software rotates, and for an accepted credential, which the vault does not remake. Never answers " + + "the value. From the controller alone, and with a why.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "node": str("the machine, for a module's own secret"), "module": str("the module"), + "name": str("the secret's name in the module"), "provider": str("the provider's machine"), + "provision": str("the provision"), + "consumer": str("a provision's credential of a provider whose consumers keep a login each: only " + + "this consumer's machine's own"), + "consumer_module": str("with it or alone: only this consuming module's own login"), + "by": str("who asked, as the controller names them when it carries the act"), + "why": str("why it is rotated: required")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + secret, err := secretNamed(a) + if err != nil { + return nil, err + } + one, err := narrowNamed(a) + if err != nil { + return nil, err + } + return v.RotateFor(caller, secret, one, arg(a, "by"), arg(a, "why")) + }}, + {Name: Seat + ".move-in", + Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " + + "node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " + + "module and name for a module's own; provider, provision, consumer, consumer_module and local for a " + + "pair credential), its origin and the controller's generation, and get a call and a key made for it; " + + "then send the call, the value sealed to that key and read_back_to, a key of your own. Never replaces " + + "a value the vault holds; answers what it kept, read back from its store sealed to read_back_to.", + Input: intakeInput(map[string]string{"node": "a module's own secret: its machine", "module": "its module", + "name": "its name in the module", "provider": "a pair credential: the provider's machine", + "provision": "the provision", "consumer": "the consumer's machine", + "consumer_module": "the consuming module", "local": "the credential's local name, when it has one", + "origin": "where the value came from in the controller's store: made, accepted or issued", + "generation": "the controller's generation for the value, kept as it is", + "made_at": "when the controller made or took it (RFC 3339)", + "read_back_to": "the second step: a raw X25519 public key in base64 the read-back is sealed to", + "sealing_key": "the first step, optional: this machine's sealing key, a raw X25519 public key in " + + "base64, which the core's credentials are sealed to from then on (ADR 0349 §16)"}), + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.MoveTake(caller, call, arg(a, "sealed"), arg(a, "read_back_to")) + } + m := MoveOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"), + Provider: arg(a, "provider"), Provision: arg(a, "provision"), Consumer: arg(a, "consumer"), + ConsumerModule: arg(a, "consumer_module"), Local: arg(a, "local"), Origin: arg(a, "origin"), + Generation: arg(a, "generation"), SealingKey: arg(a, "sealing_key")} + if at := arg(a, "made_at"); at != "" { + if m.MadeAt, err = time.Parse(time.RFC3339Nano, at); err != nil { + return nil, fmt.Errorf("made_at %q is not a time (RFC 3339)", at) + } + } + return v.MoveOpen(caller, m) + }}, + {Name: Seat + ".node-key", + Description: "State this machine's sealing key to the vault, in its own name (ADR 0372, issue 786): from the " + + "node-engine of the machine alone, at its start and whenever the key changes; the machine is the caller's, " + + "as the bus proved it. A first or changed key is recorded and `secret-changed` is raised for the machine's " + + "core secrets, so the controller asks again; the same key again changes nothing.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "key": str("this machine's sealing key, a raw X25519 public key in base64, as move-in's sealing_key carries it")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if err := v.StateNodeKey(caller, arg(a, "key")); err != nil { + return nil, err + } + return map[string]any{"recorded": true}, nil + }}, + {Name: Seat + ".move-asks", + Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " + + "(design 57's V3): from the controller alone, at most 500 at a time. No value is in any of them.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "asks": map[string]any{"type": "array", "description": "the controller's rows: id, node, module, name, " + + "desk, asked_by, opened_at, and ended_at and outcome when the ask ended", + "items": map[string]any{"type": "object"}}}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + raw, err := json.Marshal(a["asks"]) + if err != nil { + return nil, err + } + var asks []MovedAsk + if err := json.Unmarshal(raw, &asks); err != nil { + return nil, fmt.Errorf("asks is not the controller's rows: %w", err) + } + return v.MoveAsks(caller, asks) + }}, + {Name: "secret_asks", + Description: "The record of asks for a module's own secret at a desk, newest first: who asked as the bus named " + + "them, for what, at which desk, when, and how each ended. Never a value.", + Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many lines (default 50)"}}, + run: func(_ string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + return v.Store.Asks(limitOf(a)) + }}, {Name: "secret_export", Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " + "sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " + @@ -204,6 +401,11 @@ func definitions(env *Env) []toolDef { } return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil }}, + {Name: "vault_health", + Description: "The vault's runtime health, as the node-engine asks it (novox/hq ADR 0366): unhealthy once " + + "re-sealing the operator copies has failed on three passes in a row, saying what failed and why; " + + "healthy again after one pass succeeds.", + run: func(string, map[string]any) (any, error) { return env.health(), nil }}, {Name: "vault_status", Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " + "key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.", @@ -237,7 +439,8 @@ func actInput(set, one bool) map[string]any { } // actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the -// runtime proved. +// runtime proved. `by` is an argument, so it is not proven: when it names somebody other than the caller the +// runtime proved, the proven caller is kept beside it in via, so the record says both. func actOf(caller string, a map[string]any) Act { act := Act{} act.Why, _ = a["why"].(string) @@ -246,9 +449,70 @@ func actOf(caller string, a map[string]any) Act { if act.By == "" { act.By = caller } + if act.By != caller { + proven := "called by " + caller + if act.Via == "" { + act.Via = proven + } else { + act.Via += ", " + proven + } + } return act } +// ready is the vault, or why there is none. +func (e *Env) ready() (*Vault, error) { + if e.Vault != nil { + return e.Vault, nil + } + if e.Broken != nil { + return nil, e.Broken + } + return nil, errors.New("the vault is not ready") +} + +func arg(a map[string]any, key string) string { + s, _ := a[key].(string) + return strings.TrimSpace(s) +} + +// secretNamed is the secret a call names: a module's own (node, module, name), or a provision's one credential +// (provider, provision) — one or the other. +func secretNamed(a map[string]any) (string, error) { + own := arg(a, "node") != "" || arg(a, "module") != "" || arg(a, "name") != "" + pair := arg(a, "provider") != "" || arg(a, "provision") != "" + switch { + case own && !pair: + return OwnSecret(arg(a, "node"), arg(a, "module"), arg(a, "name")) + case pair && !own: + return ProvisionSecret(arg(a, "provider"), arg(a, "provision")) + } + return "", errors.New("name a module's own secret (node, module, name) or a provision's credential " + + "(provider, provision), one of the two") +} + +// narrowNamed is the one consumer a rotation names, if any (issue 767). +func narrowNamed(a map[string]any) (Narrow, error) { + n := Narrow{Consumer: arg(a, "consumer"), ConsumerModule: arg(a, "consumer_module")} + for what, v := range map[string]string{"the consumer's machine": n.Consumer, "the consuming module": n.ConsumerModule} { + if v != "" && !namePart.MatchString(v) { + return Narrow{}, fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return n, nil +} + +// intakeInput is a two-step verb's arguments: what its first step names, then the call and what was sealed. +func intakeInput(first map[string]string) map[string]any { + props := map[string]any{} + for k, d := range first { + props[k] = str(d) + } + props["call"] = str("the second step: the call the first step answered") + props["sealed"] = str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value") + return map[string]any{"type": "object", "properties": props} +} + func stringsOf(v any) []string { list, _ := v.([]any) out := []string{} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 2ba4ec53..817bb3b3 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -7,6 +7,7 @@ package main import ( "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" "regexp" @@ -30,11 +31,21 @@ type Vault struct { Own seal.KeyPair // Askers decides who may ask `get` for what. Nil allows nobody. Askers Askers - Emit func(event string, body map[string]any) error - Now func() time.Time - Log func(format string, args ...any) + // Assignments is the controller's assignments mirror, where the vault sees a consumer bind a provider and + // unbind (pair.go). Empty sees nothing, and makes no provision's credential. + Assignments Mirror + Emit func(event string, body map[string]any) error + // Ask calls a tool on the mesh as the vault, through the runtime that launched it, under the vault's own bus + // account (ADR 0259 §8's runs-as, issue 723): the desks' secret prompt and each issuing provider's tool, which + // the bus lets the vault's account alone call in its own name. Nil asks nobody. + Ask func(key string, body any) (json.RawMessage, error) + Now func() time.Time + Log func(format string, args ...any) mu sync.Mutex + // callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere. + callsMu sync.Mutex + pending map[string]*callKey } // Changed is what one change to a secret was. Never its value. @@ -44,6 +55,8 @@ type Changed struct { At time.Time `json:"at"` By string `json:"by"` Unchanged bool `json:"unchanged,omitempty"` + // Each is, for a rotation of a provider whose consumers keep logins of their own, each one's own value remade. + Each []Changed `json:"each,omitempty"` } // Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is. @@ -65,6 +78,25 @@ func (v *Vault) log(format string, args ...any) { // Refused: a private key (it stays where it was made), and any value while the vault has no operator key — // a secret only the vault could open is what ADR 0113 §22 rules out. func (v *Vault) Put(name, value, by string) (Changed, error) { + return v.keep(name, value, by, "", OriginMade) +} + +// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who +// by names (via). +func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) { + return v.keepWith(name, value, by, via, origin, keepOptions{}) +} + +// keepOptions are what the move keeps beside a value (move.go): that it never replaces another, when the value +// was first made, and where it was moved from. +type keepOptions struct { + neverReplace bool + madeAt time.Time + movedFrom *MovedFrom +} + +// keepWith is keep with the move's options. +func (v *Vault) keepWith(name, value, by, via, origin string, opt keepOptions) (Changed, error) { if err := validName(name); err != nil { return Changed{}, err } @@ -94,6 +126,11 @@ func (v *Vault) Put(name, value, by string) (Changed, error) { if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value { return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil } + if opt.neverReplace { + return Changed{}, fmt.Errorf("the vault already holds another value for %s (generation %d, changed by %s "+ + "at %s), and the move never replaces a value the vault holds: nothing was taken", name, + before.Generation, before.ChangedBy, before.ChangedAt.Format(time.RFC3339)) + } } atRest, err := seal.Seal(value, v.Own.PublicKey) if err != nil { @@ -104,11 +141,21 @@ func (v *Vault) Put(name, value, by string) (Changed, error) { return Changed{}, err } now := v.Now().UTC() + if via == by { + via = "" + } rec := Record{Name: name, Generation: 1, AtRest: atRest, Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator}, - MadeAt: now, ChangedAt: now, ChangedBy: by} + MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin} if held { rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt + } else if !opt.madeAt.IsZero() { + rec.MadeAt = opt.madeAt.UTC() + } + if opt.movedFrom != nil { + moved := *opt.movedFrom + moved.At = now + rec.MovedFrom = &moved } if err := v.Store.Write(rec); err != nil { return Changed{}, err @@ -176,7 +223,20 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) { if err := validName(name); err != nil { return Answer{}, err } - if ok, why := askers.May(caller, name); !ok { + // A value issued for one holder is that holder's alone, whatever else would allow it (issue 749). + if why := notTheHolder(caller, name); why != "" { + v.log("refused %s to %s: %s", name, caller, why) + _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why}) + return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why) + } + // The core's own credentials, to the controller alone, sealed only to their machine's key (ADR 0349 §16). + if caller == ControllerCaller { + if why := v.coreRefusal(name, publicKey); why != "" { + v.log("refused %s to %s: %s", name, caller, why) + _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why}) + return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why) + } + } else if ok, why := askers.May(caller, name); !ok { v.log("refused %s to %s: %s", name, caller, why) _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why}) return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why) diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 6c7d43e0..db21a44a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -129,7 +129,8 @@ func filesUnder(t *testing.T, dir string) map[string]string { // ---- no bucket holds a secret --------------------------------------------------------------------------- // The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own -// store on its own disk (ADR 0349 §7, ADR 0201 §13). +// store on its own disk (ADR 0349 §7, ADR 0201 §13). It reads one bucket of another's, and only reads it: the +// controller's assignments, for what each node's plan names (askers.go, design 57 V4). func TestNoBucketHoldsASecret(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -142,6 +143,9 @@ func TestNoBucketHoldsASecret(t *testing.T) { if s, ok := manifest["state"]; ok { t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s) } + if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` { + t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r) + } sources, _ := filepath.Glob("*.go") bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`) for _, f := range sources { @@ -149,6 +153,10 @@ func TestNoBucketHoldsASecret(t *testing.T) { continue } code, _ := os.ReadFile(f) + // The reads it makes, and nothing that writes: Get and Keys alone, on the assignments (Keys for the + // binds it remakes a provider's credential on, pair.go). + code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 1)) + code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Keys(", "", 1)) if m := bucket.Find(code); m != nil { t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m) } @@ -367,17 +375,143 @@ func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) { } } -// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault -// that runs answers nobody. -func TestTheRunningVaultAnswersNobodyYet(t *testing.T) { - v := newVault(t, nil) // what main builds: no list +// A vault given no rule answers nobody. +func TestAVaultWithNoRuleAnswersNobody(t *testing.T) { + v := newVault(t, nil) if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { t.Fatal(err) } asker, _ := seal.GenerateKeyPair() - for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} { + for _, caller := range []string{"provisioner.g14", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} { if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil { - t.Errorf("the running vault answered %s", caller) + t.Errorf("a vault with no rule answered %s", caller) + } + } +} + +// assignments stands in for the controller's assignments mirror as the vault reads it. +type assignments map[string]string + +func (a assignments) read(key string) ([]byte, error) { + if v, ok := a[key]; ok { + return []byte(v), nil + } + return nil, nil +} + +// The running vault's rule (ADR 0349 §11, design 57 V4): a node's provisioner client, under its own account, for a +// secret of a module on its node that the module's plan there names, and nobody else for anything else. Each +// refusal is recorded with why. +func TestTheRunningVaultAnswersAMachinesClientForWhatItsPlanNames(t *testing.T) { + mirror := assignments{ + "g14.dunst": `{"node":"g14","module":"dunst","assigned":"2026-10-14T20:00:00Z","provisioning":` + + `{"templates":{"source":"x","digest":"y"},"files":[],"secrets":[{"name":"token"}]}}`, + "g14.picom": `{"node":"g14","module":"picom","assigned":"2026-10-14T20:00:00Z"}`, + "ace.dunst": `{"node":"ace","module":"dunst","assigned":"2026-10-14T20:00:00Z","provisioning":{"secrets":[{"name":"token"}]}}`, + "g14.liar": `{"node":"ace","module":"liar","provisioning":{"secrets":[{"name":"token"}]}}`, + "g14.unparsed": `not json`, + } + v := newVault(t, Declared{Read: mirror.read}) + for _, name := range []string{"own/g14/dunst/token", "own/g14/dunst/other", "own/g14/picom/token", + "own/ace/dunst/token", "own/g14/liar/token", "own/g14/unparsed/token", "provision/novox/postgres", + "own/g14/absent/token"} { + if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil { + t.Fatal(err) + } + } + asker, _ := seal.GenerateKeyPair() + + answer, err := v.Get("provisioner.g14", "own/g14/dunst/token", asker.PublicKey) + if err != nil { + t.Fatalf("the machine's client was refused the secret its module's plan names: %v", err) + } + if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-own/g14/dunst/token" { + t.Fatalf("the answer opens to %q (%v)", got, err) + } + + refusals := []struct{ caller, name, why string }{ + {"provisioner.g14", "own/g14/dunst/other", "a secret the plan does not name"}, + {"provisioner.g14", "own/g14/picom/token", "a module not handed to the provisioner"}, + {"provisioner.g14", "own/ace/dunst/token", "another machine's secret"}, + {"provisioner.ace", "own/g14/dunst/token", "another machine's client"}, + {"provisioner.g14", "own/g14/absent/token", "a module not assigned there"}, + {"provisioner.g14", "own/g14/liar/token", "an assignment whose value names another machine"}, + {"provisioner.g14", "own/g14/unparsed/token", "an assignment that is not the controller's"}, + {"provisioner.g14", "provision/novox/postgres", "a provision's credential the vault made for nobody there"}, + {"g14.node-tools", "own/g14/dunst/token", "the tool runner's account, which an agent shares"}, + {"g14.dunst", "own/g14/dunst/token", "a module's own account, until issue 569"}, + {"controller", "own/g14/dunst/token", "the controller"}, + {"provisioner.", "own/g14/dunst/token", "a client naming no machine"}, + } + for _, r := range refusals { + if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil { + t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a) + } + } + handed, err := v.Store.Handed(100) + if err != nil { + t.Fatal(err) + } + refused := 0 + for _, h := range handed { + if h.Outcome == "refused" { + if h.Why == "" { + t.Errorf("a refusal of %s to %s is recorded without why", h.Name, h.To) + } + refused++ + } + } + // Every refusal but the last, whose caller is not a bus user's shape and so fills no record (vault.go). + if refused != len(refusals)-1 { + t.Errorf("%d refusals recorded, want %d", refused, len(refusals)-1) + } +} + +// A value a provider's software issued for one holder (issue 757): the running rule hands +// provision//// to that node's client when the module's plan there names the +// provision from that provider, and refuses another node's, another module's, another provider's and the provision's +// one credential. +func TestTheRunningVaultAnswersAMachinesClientForAnIssuedValueItsPlanNames(t *testing.T) { + plan := `{"templates":{"source":"x","digest":"y"},"files":[],` + + `"provisions":[{"name":"gitea-read","provision":"gitea-read","provider":"novox","path":"/s/gitea-read.secret"}]}` + mirror := assignments{ + "anchor.claude-code": `{"node":"anchor","module":"claude-code","provisioning":` + plan + `}`, + "laptop.claude-code": `{"node":"laptop","module":"claude-code","provisioning":` + plan + `}`, + "anchor.zsh": `{"node":"anchor","module":"zsh","provisioning":{"secrets":[{"name":"token"}]}}`, + "anchor.picom": `{"node":"anchor","module":"picom"}`, + } + v := newVault(t, Declared{Read: mirror.read}) + for _, name := range []string{anchorsToken, laptopsToken, "provision/novox/gitea-read/anchor/zsh", + "provision/novox/gitea-read/anchor/picom", "provision/ace/gitea-read/anchor/claude-code", + "provision/novox/other/anchor/claude-code", "provision/novox/gitea-read", + "provision/novox/gitea-read/anchor/absent"} { + if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil { + t.Fatal(err) + } + } + asker, _ := seal.GenerateKeyPair() + answer, err := v.Get("provisioner.anchor", anchorsToken, asker.PublicKey) + if err != nil { + t.Fatalf("the machine's client was refused the value issued for its module, which its plan names: %v", err) + } + if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-"+anchorsToken { + t.Fatalf("the answer opens to %q (%v)", got, err) + } + refusals := []struct{ caller, name, why string }{ + {"provisioner.anchor", laptopsToken, "another machine's holder value"}, + {"provisioner.laptop", anchorsToken, "another machine's holder value, the other way"}, + {"provisioner.anchor", "provision/novox/gitea-read/anchor/zsh", "another module's, whose plan names it not"}, + {"provisioner.anchor", "provision/novox/gitea-read/anchor/picom", "a module not handed to the provisioner"}, + {"provisioner.anchor", "provision/novox/gitea-read/anchor/absent", "a module not assigned there"}, + {"provisioner.anchor", "provision/ace/gitea-read/anchor/claude-code", "another provider's"}, + {"provisioner.anchor", "provision/novox/other/anchor/claude-code", "a provision the plan names not"}, + {"provisioner.anchor", "provision/novox/gitea-read", "the provision's one credential"}, + {"anchor.node-tools", anchorsToken, "the tool runner's account, which an agent shares"}, + {"anchor.claude-code", anchorsToken, "the module's own account, until issue 569"}, + } + for _, r := range refusals { + if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil { + t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a) } } } diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index 454acc9d..b025e17a 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -4,7 +4,19 @@ go 1.25.0 require ( git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.51.0 golang.org/x/crypto v0.50.0 ) -require golang.org/x/sys v0.43.0 // indirect +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index afe914a8..da3d1f5d 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,6 +1,27 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 29c631f5..83ee6cd4 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -1,6 +1,7 @@ { "module": "mesh-vault", "version": "1", + "runs-as": "mesh-vault", "provides": [ { "name": "secret", @@ -15,6 +16,9 @@ "rotated", "secret-changed" ], + "reads": [ + "mesh-controller.assignments" + ], "receives": { "secret": "${dir:grants}/mesh.json" }, @@ -57,32 +61,56 @@ } } }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-vault", + "runtime-health": { + "kind": "tool", + "tool": "vault_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-vault", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-vault" + }, { "id": "state", "type": "directory", "mode": "0700", + "owner": "mesh-vault", "place": "." }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault" }, { "id": "ledger", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault" }, { "id": "root", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault" }, { "id": "store", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault" } ], "build": { @@ -112,19 +140,28 @@ "secret_holders", "secret_verify", "secret_handed", + "secret_asks", "secret_vault_export", "provisioner_retirement", "provisioner_retire_approve", "provisioner_retire_reject", "provisioner_delete", - "vault_status" + "vault_status", + "vault_health" ], "claims": [ { "name": "mesh-vault", "scope": "mesh", "serves": [ - "get" + "get", + "accept", + "secret-ask", + "issued", + "rotate", + "move-in", + "node-key", + "move-asks" ] } ] diff --git a/modules/mesh-watcher/cmd/mesh-watcher/templates_test.go b/modules/mesh-watcher/cmd/mesh-watcher/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/mesh-watcher/cmd/mesh-watcher/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/mesh-watcher/files/settings.json b/modules/mesh-watcher/files/settings.json new file mode 100644 index 00000000..84b9ecc1 --- /dev/null +++ b/modules/mesh-watcher/files/settings.json @@ -0,0 +1,3 @@ +{ + "telegram-chat-id": "" +} diff --git a/modules/mesh-watcher/module.json b/modules/mesh-watcher/module.json index 6cbbfb33..798fcf20 100644 --- a/modules/mesh-watcher/module.json +++ b/modules/mesh-watcher/module.json @@ -18,6 +18,14 @@ "watcher_test", "watcher_ping" ], + "config": { + "telegram-chat-id": { + "type": "string", + "description": "The Telegram chat the watcher reports to: the chat's id, a number, or @name of a channel; empty reports to none.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", @@ -30,8 +38,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["telegram-chat-id"], "merge": "json", - "content": "{\n \"telegram-chat-id\": \"\"\n}\n" + "template": "files/settings.json" } ], "build": { @@ -50,6 +59,11 @@ "MESH_WATCHER_SETTINGS": "${dir:state}/settings.json", "MESH_WATCHER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 7a70a347..10b86afd 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -49,8 +49,9 @@ channel's client and no channel's secret. warrant published to the asker alone. A warrant the bus did not take is published again every minute. - **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given, that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was - told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how - it ended, as its title ("You chose Approve at 13:30 on Telegram · "), with no answers left on it. + told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no + answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended + ("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714). What the asker then did is not said: the asks protocol carries no result back to the router, so the message says only that the asker was told and acts on it itself. - **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would. @@ -138,8 +139,9 @@ and said in the log). Everything else is the message's note (`asks.Message.Note` smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered; and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to -the secret rule alone, as the whole words are. Answered, the message is one line — -"You chose Approve at 16:06 on Telegram · " — and the router's word on the asker is its note. +the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the +operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram." +with the router's word on the asker. **An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index 76a9cbcb..d92bf1af 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -86,7 +86,9 @@ func (h *Holder) choices(r *Record, kind string) []Choice { if rec := h.openAsk(r); rec != nil { out = h.offers(rec, kind) } - if r.Origin != "condition" || len(out) >= MostActions { + // A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is + // offered as for any message") — it is its own message, so Details just shows it again. + if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions { return out } now := h.Now() diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 828629cc..3410abb8 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -365,10 +365,12 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { for _, m := range ch.edits { edited = m } - // The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372). - if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" || - edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." { - t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note) + // The question stays whole (issue 714); below it, how it ended and what the router knows of the asker + // (issue 372). + if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" || + edited.Body != heldAsk(c, "d-1").Explanation || + edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." { + t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note) } } // The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question @@ -624,8 +626,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) { for _, m := range tg.edits { edited = m } - if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") || - edited.Body != "" || edited.Note != "The delivery stays held." { + if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") || + edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." { t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited) } } diff --git a/modules/messenger/cmd/messenger/feedback_test.go b/modules/messenger/cmd/messenger/feedback_test.go index fc7c8859..e3657c77 100644 --- a/modules/messenger/cmd/messenger/feedback_test.go +++ b/modules/messenger/cmd/messenger/feedback_test.go @@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t } want := []string{ "send You chose Release: recorded, and mesh-delivery was told.", - "edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours", + "edit Delivery of hq held for 36 hours", } if strings.Join(log, "\n") != strings.Join(want, "\n") { t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n")) @@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) { for _, m := range tg.edits { edited = m } - if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") { + if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") { t.Errorf("the question claims its asker was told: %q", edited.Note) } }) diff --git a/modules/messenger/cmd/messenger/glance_test.go b/modules/messenger/cmd/messenger/glance_test.go index 669f38a3..223caeb9 100644 --- a/modules/messenger/cmd/messenger/glance_test.go +++ b/modules/messenger/cmd/messenger/glance_test.go @@ -9,8 +9,8 @@ import ( ) // novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points -// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered -// it is one line with the headline. +// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered +// it keeps the question whole, so the operator can read back what they answered (issue 714). // decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form). func decisionAsk(c *clock) asks.Ask { @@ -59,7 +59,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) { if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 { t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text()) } - // Answered, it is one line with the headline; the body is not said again. + // Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it; + // its buttons are gone (issue 714: the one line issue 639 made left nothing to read back). if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" { t.Fatalf("Approve: %q %v", out, err) } @@ -67,8 +68,9 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) { for _, e := range tg.edits { edited = e } - if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" || - strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 { + if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] || + edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." || + len(edited.Actions) != 0 { t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note) } } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 67310e9a..9182672c 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -321,30 +321,33 @@ type Holder struct { work sync.Mutex // one event, call or tick at a time mu sync.Mutex // what the status reads - open map[string]*Record - recent []Sent - refusals []RefusalNote - outbox map[string][]item - lastSend map[string]time.Time - lastWarn map[string]time.Time - lastTry map[string]time.Time - capSaid map[string]time.Time - chanErr map[string]string - chanErrAt map[string]time.Time - chanOK map[string]time.Time - unreadable int - lastBad string - lastBadAt time.Time - saidOnce map[string]time.Time - storeErr string - heard map[string]int - lastHeard time.Time - started time.Time - syncedAt time.Time - syncErr string - wantSync bool - history int - stale int + open map[string]*Record + recent []Sent + refusals []RefusalNote + outbox map[string][]item + lastSend map[string]time.Time + lastWarn map[string]time.Time + lastTry map[string]time.Time + capSaid map[string]time.Time + tellCapSaid map[string]time.Time + chanErr map[string]string + chanErrAt map[string]time.Time + chanOK map[string]time.Time + unreadable int + lastBad string + lastBadAt time.Time + saidOnce map[string]time.Time + storeErr string + heard map[string]int + lastHeard time.Time + started time.Time + syncedAt time.Time + syncErr string + wantSync bool + history int + stale int + // tellSeq makes every tell's key its own, even when two calls land in the same instant. + tellSeq int // answered is what came of each answer event, by its id: a redelivered one is acted on once. answered map[string]string // asks are the asks this router holds, by `.`, and tickets each ask's tickets to it. @@ -374,6 +377,7 @@ func (h *Holder) init() { h.lastWarn = map[string]time.Time{} h.lastTry = map[string]time.Time{} h.capSaid = map[string]time.Time{} + h.tellCapSaid = map[string]time.Time{} h.chanErr = map[string]string{} h.chanErrAt = map[string]time.Time{} h.chanOK = map[string]time.Time{} @@ -856,7 +860,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { } } if r.Answered != "" { - m = h.answeredWords(r, m) + m = h.answeredWords(r, m, kind) } if kind == "" { return m @@ -910,18 +914,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { return m } -// answeredWords is a message that says what came of its question. An ask's own message is the question -// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram · -// " — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body -// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's -// message keeps its own title, and says it first. -func (h *Holder) answeredWords(r *Record, m Message) Message { +// answeredWords is a message that says what came of its question. An ask's own message keeps the question +// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them — +// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended +// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note, +// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from +// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and +// says how it ended first. +func (h *Holder) answeredWords(r *Record, m Message, kind string) Message { if r.Origin == "ask" { - first, rest, _ := strings.Cut(r.Answered, "\n") headline, _ := wordsOf(r) - out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest), + body := []string{} + if b := strings.TrimSpace(m.Body); b != "" { + body = append(body, b) + } + body = append(body, h.answeredDetails(r, kind)...) + out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered), Urgent: m.Urgent, Quiet: m.Quiet} - if _, ok := Check(out.Text()); ok { + if _, ok := Check(out.Title + "\n" + out.Note); ok { return out } // Its headline refused (it is the asker's): the outcome is still said, before what may leave. @@ -930,6 +940,33 @@ func (h *Holder) answeredWords(r *Record, m Message) Message { return m } +// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole +// on a kind that proves who answers, a line that may not leave withheld and said so. +func (h *Holder) answeredDetails(r *Record, kind string) []string { + if r.Ask == "" { + return nil + } + e := h.askOf(r.Ask) + if e == nil { + return nil + } + details, passes := e.rec.Ask.Details, Check + if wd := h.wholeDetailsOf(r, kind); wd != "" { + details, passes = wd, CheckSecret + } + var lines []string + for _, line := range strings.Split(strings.TrimSpace(details), "\n") { + if line = strings.TrimSpace(line); line == "" { + continue + } + if refusal, ok := passes(line); !ok { + line = withheldLine(line, refusal, passes) + } + lines = append(lines, line) + } + return lines +} + // wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no // secret's shape once composed; else false, and the message under the whole content rule stands. func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) { diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index b7a27309..f4cfd76e 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -524,6 +524,161 @@ func TestNotifyIsKeptApartFromConditions(t *testing.T) { } } +func TestATellReachesEveryChannelOnceAndOffersOnlyDetails(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + c.pass(14 * time.Hour) // well past midnight: it is not held for being "night" — there is no such hold + out, err := h.Tell("new mail arrived for ace", []string{"One message from the inbox you watch."}, "mail") + if err != nil { + t.Fatal(err) + } + if len(tg.sends) != 1 || len(dt.sends) != 1 { + t.Fatalf("telegram %d, desktop %d sends", len(tg.sends), len(dt.sends)) + } + for _, m := range []Message{tg.sends[0], dt.sends[0]} { + if len(m.Actions) != 1 || m.Actions[0].Label != DetailsLabel { + t.Fatalf("actions %+v", m.Actions) + } + } + if sent, _ := out["sent_on"].([]string); len(sent) != 2 { + t.Fatalf("sent_on %v", out) + } + if len(h.Open()) != 0 { + t.Fatalf("a tell left an open record: %+v", h.Open()) + } + c.pass(RemindUrgent + time.Minute) + h.Tick() + if len(tg.sends) != 1 || len(dt.sends) != 1 { + t.Fatalf("a tell was reminded: telegram %d, desktop %d", len(tg.sends), len(dt.sends)) + } +} + +func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) { + h, tg, _, _, _ := newHolder(t) + h.Desk = nil + for i := 0; i < TellCapPerHour; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + if len(tg.sends) != TellCapPerHour { + t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour) + } + out, err := h.Tell("mail", []string{"line"}, "") + if err != nil { + t.Fatal(err) + } + if len(tg.sends) != TellCapPerHour { + t.Fatalf("the 11th tell was sent: %d", len(tg.sends)) + } + dropped, _ := out["dropped"].([]string) + if len(dropped) != 1 || dropped[0] != "telegram" { + t.Fatalf("dropped %v", out) + } +} + +func TestATellDoesNotCountTowardTheConditionsCap(t *testing.T) { + h, tg, _, c, _ := newHolder(t) + h.Desk = nil + // Bring conditions close to, but under, their own cap: CapPerHour-5. + for i := 0; i < CapPerHour-5; i++ { + h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + } + if len(tg.sends) != CapPerHour-5 { + t.Fatalf("sent %d conditions, want %d", len(tg.sends), CapPerHour-5) + } + // A full hour of tells: these must not be counted against the conditions' cap (ADR 0380 §8). + for i := 0; i < TellCapPerHour; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + want := CapPerHour - 5 + TellCapPerHour + 1 + if len(tg.sends) != want { + t.Fatalf("sent %d, want %d (the condition after the tells still sent)", len(tg.sends), want) + } + if last := tg.sends[len(tg.sends)-1]; strings.HasPrefix(last.Title, "Mesh messages paused for this hour") { + t.Fatalf("the condition was held behind the tells' sends: %q", last.Title) + } +} + +func TestATellCapDropIsLoggedOnceAnHour(t *testing.T) { + h, tg, _, _, _ := newHolder(t) + h.Desk = nil + var logs []string + h.Logf = func(format string, args ...any) { logs = append(logs, fmt.Sprintf(format, args...)) } + for i := 0; i < TellCapPerHour; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 3; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + if len(tg.sends) != TellCapPerHour { + t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour) + } + dropLogs := 0 + for _, l := range logs { + if strings.Contains(l, "dropped") { + dropLogs++ + } + } + if dropLogs != 1 { + t.Fatalf("logged the drop %d times, want 1", dropLogs) + } +} + +// TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory is review round 2 of issue 800's messenger +// finding (issue 797's area): a dropped tell used to be kept in the same bounded send history the conditions' +// cap counts from (KeptSends); a flood of them could evict real, still-recent sends from it, undercounting +// sentLastHour and letting the conditions' cap stop holding. Here the cap is filled exactly, then 300 tells +// are dropped — far more than KeptSends — and the next condition must still be held, exactly as it would be +// without the flood. +func TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory(t *testing.T) { + h, tg, _, c, _ := newHolder(t) + h.Desk = nil + // Fill the conditions' cap exactly: CapPerHour sent, none held yet. + for i := 0; i < CapPerHour; i++ { + h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + } + if len(tg.sends) != CapPerHour { + t.Fatalf("sent %d conditions, want the cap of %d", len(tg.sends), CapPerHour) + } + + // Use up the tell cap, then flood 300 more: all past TellCapPerHour are dropped, far more than KeptSends. + for i := 0; i < TellCapPerHour; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 300; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + + // One more condition, past the cap: it must still be held, as it would be without the flood of drops. + h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + want := CapPerHour + TellCapPerHour + 1 // the cap's own conditions, the tells that did send, and the cap message + if len(tg.sends) != want { + t.Fatalf("sent %d, want %d (the cap still held after a flood of dropped tells)", len(tg.sends), want) + } + if last := tg.sends[len(tg.sends)-1]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") { + t.Fatalf("the cap was not said after the flood of dropped tells: %q", last.Title) + } +} + func TestATestMessageRespectsTheRule(t *testing.T) { h, tg, dt, _, _ := newHolder(t) if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 { diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 3a521339..c50dcecd 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -645,6 +645,27 @@ func tools(h *Holder, l *listening) []stdio.Tool { return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear, strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved")) }}, + {Name: "operator-channel.tell", + Description: "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380): a " + + "headline and one or two lines, said at once on every channel, held to the content rule as notify's " + + "words are, and offering Details like any message. Never escalated, never kept open, never reminded; " + + "its own cap of 10 a channel an hour, apart from the conditions' and notify's.", + Input: map[string]any{ + "headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"), + "lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"}, + "subject": str("what it is about: a machine's role, a module, a plan"), + }, + Run: func(a map[string]any) (any, error) { + var lines []string + if raw, ok := a["lines"].([]any); ok { + for _, l := range raw { + if s, ok := l.(string); ok { + lines = append(lines, s) + } + } + } + return h.Tell(strArg(a, "headline"), lines, strArg(a, "subject")) + }}, {Name: "messenger_status", Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " + "send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " + diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 3ae820f8..4e94d8f3 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -62,7 +62,7 @@ func readManifest(t *testing.T) (manifest, string) { func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { m, _ := readManifest(t) - verbs := []string{"open", "history", "notify", "asks"} + verbs := []string{"open", "history", "notify", "tell", "asks"} want := []seat{ {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}}, diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index 7e9d2ae7..ab11d4d3 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -609,7 +609,9 @@ func (h *Holder) sendNow(ch Channel, key, what string, m Message) bool { return true } -// allow says whether a channel is under its cap: messages sent in the last hour, as recorded. +// allow says whether a channel is under its cap: messages sent in the last hour, as recorded. A +// tell is not counted here: it has its own cap (TellCapPerHour) so a burst of tells cannot crowd +// out a condition or an ask (ADR 0380 §8). func (h *Holder) allow(channel string) bool { return h.sentLastHour(channel) < CapPerHour } @@ -620,7 +622,30 @@ func (h *Holder) sentLastHour(channel string) int { since := h.Now().Add(-time.Hour) n := 0 for _, s := range h.recent { - if s.Channel == channel && s.Outcome == "sent" && s.At.After(since) { + if s.Channel == channel && s.What != "tell" && s.Outcome == "sent" && s.At.After(since) { + n++ + } + } + return n +} + +// TellCapPerHour: at most this many tells reach one channel in an hour — the tell verb's own cap +// (ADR 0380 §8), never shared with CapPerHour, the conditions' and notify's, so a burst of plain +// notices cannot crowd out a condition or an ask. +const TellCapPerHour = 10 + +// allowTell says whether a channel is under tell's own cap. +func (h *Holder) allowTell(channel string) bool { + return h.tellSentLastHour(channel) < TellCapPerHour +} + +func (h *Holder) tellSentLastHour(channel string) int { + h.mu.Lock() + defer h.mu.Unlock() + since := h.Now().Add(-time.Hour) + n := 0 + for _, s := range h.recent { + if s.Channel == channel && s.What == "tell" && s.Outcome == "sent" && s.At.After(since) { n++ } } diff --git a/modules/messenger/cmd/messenger/preview.go b/modules/messenger/cmd/messenger/preview.go index caa40e94..dc73f910 100644 --- a/modules/messenger/cmd/messenger/preview.go +++ b/modules/messenger/cmd/messenger/preview.go @@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) { asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it m, line, refusal, ownWords := h.allowedWords(&r, what, now) if r.Answered != "" { - m = h.answeredWords(&r, m) + m = h.answeredWords(&r, m, "") } out := map[string]any{ "key": key, diff --git a/modules/messenger/cmd/messenger/replaced_test.go b/modules/messenger/cmd/messenger/replaced_test.go index 100e0b80..467a9c0c 100644 --- a/modules/messenger/cmd/messenger/replaced_test.go +++ b/modules/messenger/cmd/messenger/replaced_test.go @@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) { m, edited = r.m, true } } - if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") { + if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") { t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m)) } } @@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T) if got := openAskRecords(h); len(got) != 0 { t.Fatalf("the message of an ended ask is still open: %v", got) } - if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") { + if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") { t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m)) } } diff --git a/modules/messenger/cmd/messenger/status.go b/modules/messenger/cmd/messenger/status.go index 2fecf348..e4192a76 100644 --- a/modules/messenger/cmd/messenger/status.go +++ b/modules/messenger/cmd/messenger/status.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "sort" + "strings" "time" ) @@ -309,3 +311,90 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plai held := h.queued(key) return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil } + +// Tell sends a plain notice that needs no answer and opens no condition (ADR 0380 §8): one message with a +// headline and one or two lines, said at once on every channel the router sends on — bypassing the severity +// routing, the burst window and the walk hold that a condition's message goes through, and never reminded, +// escalated or kept open. Composed and held to the content rule exactly as notify's words are (allowedWords' +// reduction, withheld parts as today). It offers Details, like any message, and is kept in the history so +// `history` shows it — but its record is already marked cleared, so `open` never shows it and it is forgotten +// after ReopenWindow, as any cleared record is. Its cap, TellCapPerHour a channel an hour, is its own and +// never shared with CapPerHour: past it, a tell is dropped, said once an hour in the log and named in this +// call's own "dropped" answer, but never kept in the bounded send history the conditions' cap counts from +// (KeptSends, outbox.go's sentLastHour) — a flood of drops must never evict a real, still-recent send from it +// and let that cap stop holding (review round 2 of issue 800, issue 797's area). +func (h *Holder) Tell(headline string, lines []string, subject string) (map[string]any, error) { + headline = strings.TrimSpace(headline) + if headline == "" { + return nil, errorf("headline is required") + } + if len(lines) == 0 || len(lines) > 2 { + return nil, errorf("lines takes one or two lines") + } + clean := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + return nil, errorf("a line is empty") + } + clean = append(clean, l) + } + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + h.mu.Unlock() + now := h.Now() + h.mu.Lock() + h.tellSeq++ + seq := h.tellSeq + h.mu.Unlock() + key := fmt.Sprintf("tell.%d.%d", now.UnixNano(), seq) + rec := &Record{Key: key, Kind: "tell", Subject: subject, Origin: "tell", Headline: headline, + Explanation: strings.Join(clean, "\n"), Summary: headline, Raised: now, Seen: now, Count: 1, + Sent: map[string]string{}, Cleared: now} + h.mu.Lock() + h.open[key] = rec + h.mu.Unlock() + var sentOn, dropped []string + for _, ch := range h.channels() { + name := ch.Name() + if ch.Ready() != nil { + continue + } + if !h.allowTell(name) { + dropped = append(dropped, name) + h.mu.Lock() + said := h.tellCapSaid[name] + sayIt := said.IsZero() || now.Sub(said) >= time.Hour + if sayIt { + h.tellCapSaid[name] = now + } + h.mu.Unlock() + if sayIt { + h.Logf("[messenger] a tell was dropped on %s: it is at its own cap of %d tells an hour", name, TellCapPerHour) + } + // Not h.record'd: that history is bounded (KeptSends) and is what the conditions' cap counts from + // (sentLastHour, outbox.go); a flood of drops must never evict a real send from it and let the + // conditions' cap stop holding (review round 2 of issue 800, issue 797's area). This call's own + // "dropped" answer, and the once-an-hour log line above, already say it happened. + continue + } + m := h.sayOn(rec, "tell", name) + id, err := ch.Send(m) + h.noteChannel(name, err) + if err != nil { + h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "failed: " + err.Error()}) + continue + } + h.mu.Lock() + rec.Sent[name] = id + h.mu.Unlock() + sentOn = append(sentOn, name) + h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "sent"}) + } + h.persist(rec) + sort.Strings(sentOn) + sort.Strings(dropped) + return map[string]any{"key": key, "sent_on": sentOn, "dropped": dropped, "refused": rec.Refused}, nil +} diff --git a/modules/messenger/cmd/messenger/templates_test.go b/modules/messenger/cmd/messenger/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/messenger/cmd/messenger/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index 93b3e4d3..e696f832 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -61,8 +61,7 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen { t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State) } - // Approved, the message is edited to one line that says so, with the headline; the body is not said again - // (issue 639). + // Approved, the message keeps its headline and words, and says below them how it ended (issue 714). if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" { t.Fatalf("Release: %q %v", out, err) } @@ -70,8 +69,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes if !ok { t.Fatalf("the phone's message was not edited: %+v", tg.edits) } - if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") || - edited.Body != "" || len(edited.Actions) != 0 { + if edited.Title != "Set mounts on shanks?" || edited.Body == "" || + !strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 { t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions) } } diff --git a/modules/messenger/cmd/messenger/words.go b/modules/messenger/cmd/messenger/words.go index b3546232..1a450d85 100644 --- a/modules/messenger/cmd/messenger/words.go +++ b/modules/messenger/cmd/messenger/words.go @@ -63,6 +63,10 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold if urgent { title = "Urgent: " + title } + case "tell": + // A plain notice (ADR 0380 §1, §8): the headline and its one or two lines, nothing else — no + // "since", no severity, no reminder: it waits for no answer and is never said again. + return Message{Title: headline, Body: explanation} default: title = headline if urgent { diff --git a/modules/messenger/files/settings.json b/modules/messenger/files/settings.json new file mode 100644 index 00000000..ce76334c --- /dev/null +++ b/modules/messenger/files/settings.json @@ -0,0 +1,4 @@ +{ + "time-zone": "", + "only-what-needs-me": [] +} diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 3a12a074..89d0b3a6 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -11,6 +11,7 @@ "open", "history", "notify", + "tell", "asks" ], "accepts": [ @@ -62,6 +63,7 @@ "open", "history", "notify", + "tell", "asks" ] } @@ -110,6 +112,26 @@ "messenger_identities", "messenger_unlink" ], + "config": { + "time-zone": { + "type": "string", + "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", + "trusted": true, + "default": "" + }, + "desktop-machines": { + "type": "list", + "description": "The machines whose desktop showed the operator's messages, by node name. Nothing in this module reads it since the desk channel took the desktop over; it is taken so the file stays as a layer that still sets it makes it, until that layer drops it.", + "trusted": true, + "default": [] + }, + "only-what-needs-me": { + "type": "list", + "description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.", + "trusted": true, + "default": [] + } + }, "resources": [ { "id": "account", @@ -130,8 +152,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me"], "merge": "json", - "content": "{\n \"time-zone\": \"\",\n \"only-what-needs-me\": []\n}\n", + "template": "files/settings.json", "owner": "messenger" } ], @@ -151,6 +174,11 @@ "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", "MESH_MESSENGER_STATE": "${dir:state}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/minio/files/root.env b/modules/minio/files/root.env new file mode 100644 index 00000000..2255260f --- /dev/null +++ b/modules/minio/files/root.env @@ -0,0 +1,2 @@ +MINIO_ROOT_USER=meshroot +MINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console} diff --git a/modules/minio/module.json b/modules/minio/module.json index 7d6f525e..fd59501e 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -104,7 +104,7 @@ "type": "file", "path": "${dir:state}/root.env", "mode": "0600", - "content": "MINIO_ROOT_USER=meshroot\nMINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console}\n" + "template": "files/root.env" }, { "id": "data", @@ -174,6 +174,11 @@ "MESH_MINIO_MC_CONFIG": "${dir:state}/mc", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/model-usage/files/database.url b/modules/model-usage/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/modules/model-usage/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/model-usage/module.json b/modules/model-usage/module.json index a167523f..0ab5703d 100644 --- a/modules/model-usage/module.json +++ b/modules/model-usage/module.json @@ -50,7 +50,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "spool", @@ -94,6 +94,11 @@ "DATABASE_URL_FILE": "${dir:state}/database.url", "USAGE_SPOOL": "${dir:spool}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mongodb/files/server-root.secret b/modules/mongodb/files/server-root.secret new file mode 100644 index 00000000..0ccabeea --- /dev/null +++ b/modules/mongodb/files/server-root.secret @@ -0,0 +1 @@ +${secret:root} \ No newline at end of file diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index e204b863..de9d9105 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -109,7 +109,7 @@ "path": "${dir:state}/server-root.secret", "mode": "0400", "owner": "999:999", - "content": "${secret:root}" + "template": "files/server-root.secret" }, { "id": "server", @@ -155,6 +155,11 @@ "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mosquitto/files/bootstrap.env b/modules/mosquitto/files/bootstrap.env new file mode 100644 index 00000000..65b710eb --- /dev/null +++ b/modules/mosquitto/files/bootstrap.env @@ -0,0 +1,7 @@ +MESH_PROVISION_MQTT=127.0.0.1:${port:1883} +MESH_PROVISION_ADMIN_USER=mesh-admin +MESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin +MESH_DYNSEC_FILE=${dir:data}/dynamic-security.json +MESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 +MESH_MQTT_CTRL_CONTAINER=mosquitto +MESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied diff --git a/modules/mosquitto/files/mosquitto.conf b/modules/mosquitto/files/mosquitto.conf new file mode 100644 index 00000000..500c2a69 --- /dev/null +++ b/modules/mosquitto/files/mosquitto.conf @@ -0,0 +1,20 @@ +persistence true +persistence_location /mosquitto/data + +log_dest stdout +log_type warning +log_type error +log_type notice + +# Every client authenticates; identities and their per-topic ACLs are managed +# at runtime by the dynamic security plugin, whose store the plugin itself owns. +allow_anonymous false +plugin /usr/lib/mosquitto_dynamic_security.so +plugin_opt_config_file /mosquitto/data/dynamic-security.json + +# MQTT listener +listener 1883 + +# MQTT-over-WebSockets listener +listener 8081 +protocol websockets diff --git a/modules/mosquitto/module.json b/modules/mosquitto/module.json index afa4172f..e9cb913c 100644 --- a/modules/mosquitto/module.json +++ b/modules/mosquitto/module.json @@ -103,7 +103,7 @@ "path": "${dir:state}/mosquitto.conf", "mode": "0600", "owner": "1883:1883", - "content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n" + "template": "files/mosquitto.conf" }, { "id": "net", @@ -115,7 +115,7 @@ "type": "file", "path": "${dir:state}/bootstrap.env", "mode": "0600", - "content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\nMESH_MQTT_CTRL_CONTAINER=mosquitto\nMESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied\n" + "template": "files/bootstrap.env" }, { "id": "bootstrap", @@ -179,6 +179,11 @@ "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin", "MESH_MQTT_CTRL_CONTAINER": "mosquitto" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mounts/cmd/mounts/manifest_test.go b/modules/mounts/cmd/mounts/manifest_test.go index ea95543a..257ec6ce 100644 --- a/modules/mounts/cmd/mounts/manifest_test.go +++ b/modules/mounts/cmd/mounts/manifest_test.go @@ -16,7 +16,7 @@ type manifest struct { Invokes []string `json:"invokes"` Claims []map[string]any `json:"claims"` State []map[string]any `json:"state"` - Settings map[string]map[string]any `json:"settings"` + Config map[string]map[string]any `json:"config"` OwnSecrets map[string]map[string]string `json:"own-secrets"` Resources []map[string]any `json:"resources"` } @@ -31,7 +31,15 @@ func readManifest(t *testing.T) (manifest, string) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - return m, string(raw) + // And the text of each file it places, kept in files/ as its template (novox/hq ADR 0349 §2): what the + // machine is given is judged as a whole. + text := string(raw) + for _, r := range m.Resources { + if _, has := r["template"]; has { + text += templateText(t, r) + } + } + return m, text } // The tools the bundle serves are the manifest's tools and the seat's verbs, each saying whether it reads @@ -91,13 +99,14 @@ func TestTheManifestNamesWhatTheBundleServesAndWhatItNeeds(t *testing.T) { if got := PasswordFile("${dir:state}", "games"); got != strings.Replace(sec["path"], "*", "games", 1) { t.Errorf("the process reads %s, the mesh places %s", got, sec["path"]) } - if _, has := m.Settings["smb-users"]; has || len(m.Settings) != 2 { - t.Errorf("an SMB source's username is in its entry of sources, not a setting of its own: %v", m.Settings) + if _, has := m.Config["smb-users"]; has || len(m.Config) != 2 { + t.Errorf("an SMB source's username is in its entry of sources, not a config value of its own: %v", m.Config) } for _, key := range []string{"shares", "sources"} { - s := m.Settings[key] - if s["kind"] != "preference" || s["default"] != None || s["why"] == "" || !strings.Contains(raw, "${setting:"+key+"}") { - t.Errorf("setting %s: %v", key, s) + s := m.Config[key] + if s["type"] != "string" || s["default"] != None || s["description"] == "" || s["trusted"] != true || + !strings.Contains(raw, "${config:"+key+"}") { + t.Errorf("config value %s: %v", key, s) } } // A machine's or a person's name, as a word: "novox/hq" names the record, not the machine. @@ -137,7 +146,7 @@ func TestTheProcessesAreRootAndJudgedByTheirUnitAndTheHealthTool(t *testing.T) { if byID["config"]["path"] != ConfigFile { t.Errorf("the config file is %v, the code reads %s", byID["config"]["path"], ConfigFile) } - content, _ := byID["config"]["content"].(string) + content := templateText(t, byID["config"]) cfg := ParseConfig(content) if len(cfg.Problems) != 0 && !strings.Contains(strings.Join(cfg.Problems, " "), "${") { t.Errorf("the config's template reads: %v", cfg.Problems) diff --git a/modules/mounts/cmd/mounts/templates_test.go b/modules/mounts/cmd/mounts/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/mounts/cmd/mounts/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/mounts/files/mounts.conf b/modules/mounts/files/mounts.conf new file mode 100644 index 00000000..1d5f87dd --- /dev/null +++ b/modules/mounts/files/mounts.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings +# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this. +# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry. +shares=${config:shares} +sources=${config:sources} +server=${bound:nfs-share:from} +at=${bound:nfs-share:at} +account=${machine:account} +node=${machine:name} +passwords=${dir:state} +state=${dir:state} diff --git a/modules/mounts/module.json b/modules/mounts/module.json index 108133c9..9424dc1c 100644 --- a/modules/mounts/module.json +++ b/modules/mounts/module.json @@ -38,18 +38,20 @@ "per-machine": true } ], - "settings": { - "shares": { - "kind": "preference", - "default": "none", - "why": "a machine mounts no share of another until its assignment names one: space-separated name=mountpoint, each optionally :ro (hq ADR 0263 rule 5)" + "config": { + "shares": { + "type": "string", + "description": "The shares of another machine this machine mounts: space-separated name=mountpoint, each optionally :ro, or none (novox/hq ADR 0263 rule 5).", + "trusted": true, + "default": "none" + }, + "sources": { + "type": "string", + "description": "The occasional sources this machine mounts: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro, or none (novox/hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (novox/hq ADR 0283).", + "trusted": true, + "default": "none" + } }, - "sources": { - "kind": "preference", - "default": "none", - "why": "a machine has no occasional source until its assignment names one: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro (hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (hq ADR 0283)" - } - }, "own-secrets": { "smb-password-*": { "path": "${dir:state}/smb-password-*.secret", @@ -84,7 +86,7 @@ "type": "file", "path": "/etc/mesh-mounts/mounts.conf", "mode": "0644", - "content": "# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings\n# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this.\n# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry.\nshares=${setting:shares}\nsources=${setting:sources}\nserver=${bound:nfs-share:from}\nat=${bound:nfs-share:at}\naccount=${machine:account}\nnode=${machine:name}\npasswords=${dir:state}\nstate=${dir:state}\n" + "template": "files/mounts.conf" }, { "id": "bus-dir-parent", @@ -143,6 +145,11 @@ "loads": [ "mounts" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/mssql/files/backup.sql b/modules/mssql/files/backup.sql new file mode 100644 index 00000000..fd2eccec --- /dev/null +++ b/modules/mssql/files/backup.sql @@ -0,0 +1,14 @@ +SET NOCOUNT ON; +DECLARE @n sysname, @s nvarchar(max); +DECLARE c CURSOR LOCAL FAST_FORWARD FOR + SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL; +OPEN c; +FETCH NEXT FROM c INTO @n; +WHILE @@FETCH_STATUS = 0 +BEGIN + SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM'; + EXEC (@s); + FETCH NEXT FROM c INTO @n; +END +CLOSE c; +DEALLOCATE c; diff --git a/modules/mssql/files/sa.env b/modules/mssql/files/sa.env new file mode 100644 index 00000000..e7dd4445 --- /dev/null +++ b/modules/mssql/files/sa.env @@ -0,0 +1,2 @@ +ACCEPT_EULA=Y +MSSQL_SA_PASSWORD=${secret:sa} diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 2ccbba55..e30276c9 100644 --- a/modules/mssql/module.json +++ b/modules/mssql/module.json @@ -92,7 +92,7 @@ "type": "file", "path": "${dir:state}/sa.env", "mode": "0600", - "content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n" + "template": "files/sa.env" }, { "id": "data", @@ -134,7 +134,7 @@ "type": "file", "path": "${dir:state}/backup.sql", "mode": "0600", - "content": "SET NOCOUNT ON;\nDECLARE @n sysname, @s nvarchar(max);\nDECLARE c CURSOR LOCAL FAST_FORWARD FOR\n SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL;\nOPEN c;\nFETCH NEXT FROM c INTO @n;\nWHILE @@FETCH_STATUS = 0\nBEGIN\n SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM';\n EXEC (@s);\n FETCH NEXT FROM c INTO @n;\nEND\nCLOSE c;\nDEALLOCATE c;\n" + "template": "files/backup.sql" } ], "build": { @@ -159,6 +159,11 @@ "MESH_RECEIVES": "${dir:grants}/mesh.json", "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/n8n/files/n8n-database.secret b/modules/n8n/files/n8n-database.secret new file mode 100644 index 00000000..e811e882 --- /dev/null +++ b/modules/n8n/files/n8n-database.secret @@ -0,0 +1 @@ +${secret:postgres-database} \ No newline at end of file diff --git a/modules/n8n/files/server.env b/modules/n8n/files/server.env new file mode 100644 index 00000000..2f74e0f8 --- /dev/null +++ b/modules/n8n/files/server.env @@ -0,0 +1,12 @@ +N8N_HOST=${bound:route:name} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://${bound:route:name}/ +NODE_FUNCTION_ALLOW_BUILTIN=* +NODE_FUNCTION_ALLOW_EXTERNAL=* +DB_TYPE=postgresdb +DB_POSTGRESDB_HOST=${bound:postgres-database:at} +DB_POSTGRESDB_PORT=${bound:postgres-database:port} +DB_POSTGRESDB_DATABASE=${bound:postgres-database:as} +DB_POSTGRESDB_USER=${bound:postgres-database:as} +DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database diff --git a/modules/n8n/module.json b/modules/n8n/module.json index 7666572a..23874b61 100644 --- a/modules/n8n/module.json +++ b/modules/n8n/module.json @@ -80,14 +80,14 @@ "path": "${dir:state}/n8n-database.secret", "mode": "0400", "owner": "1000:1000", - "content": "${secret:postgres-database}" + "template": "files/n8n-database.secret" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "N8N_HOST=${bound:route:name}\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://${bound:route:name}/\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database\n" + "template": "files/server.env" }, { "id": "net", @@ -97,6 +97,13 @@ { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/healthz/readiness", + "status": 200, + "needs": "postgres-database" + }, "name": "n8n", "artifact": "server", "network": "n8n", @@ -152,6 +159,11 @@ "name": "server", "kind": "image", "from": "Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/nats/files/nats.conf b/modules/nats/files/nats.conf new file mode 100644 index 00000000..e1d33eb7 --- /dev/null +++ b/modules/nats/files/nats.conf @@ -0,0 +1,46 @@ +# The nats module's own server settings. Declared by the module, because a port, a TLS path +# and a store directory are properties of the container this module raises: they live in its +# image and its mounts and change when it does. +# +# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the +# whole file would have to be kept in step with a Dockerfile it never sees. + +port: 4222 +# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish +# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222 +# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing +# (connection reset), and the only way in was `docker exec`. +http: 0.0.0.0:8222 + +# The mesh's own broker certificate — the one every machine already pins by fingerprint and the +# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no +# machine's pin changes when it moves, and no second certificate exists to be wrong about. +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" +} + +# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That +# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a +# host pins this server's exact certificate and authenticates with the password the mesh minted +# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection +# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is +# "client didn't provide a certificate", which reads as a client fault. +# +# TLS is still required: a tls block is what makes it required, and verify only decides whether +# client certificates are checked. What is given up is a second factor the mesh has no machinery +# to issue or rotate — a certificate per module per node — and what is kept is stronger than a +# name check in both directions: an exact pin outward, a per-user password inward. + +jetstream { + store_dir: "/data" +} + +# Every user of the mesh, composed by the controller and rewritten whenever a module is +# assigned, a node enrols or a person's access changes. +# +# **Relative, and in this same directory, because it has to be.** An absolute include path is +# resolved relative to the including file's directory, not from the root: nats-server given +# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for +# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server. +include accounts.conf diff --git a/modules/nats/module.json b/modules/nats/module.json index d8647b3e..3c633473 100644 --- a/modules/nats/module.json +++ b/modules/nats/module.json @@ -98,7 +98,7 @@ "id": "server-conf", "type": "file", "path": "/var/lib/nats-module/conf/nats.conf", - "content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\n# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish\n# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222\n# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing\n# (connection reset), and the only way in was `docker exec`.\nhttp: 0.0.0.0:8222\n\n# The mesh's own broker certificate — the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate — a certificate per module per node — and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n", + "template": "files/nats.conf", "mode": "0644" }, { @@ -156,6 +156,11 @@ "MESH_NATS_CONTAINER": "mesh-broker-nats", "MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/nats/snapshot/templates_test.go b/modules/nats/snapshot/templates_test.go new file mode 100644 index 00000000..fbd1490a --- /dev/null +++ b/modules/nats/snapshot/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = ".." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/networkmanager/cmd/uplink-tools/templates_test.go b/modules/networkmanager/cmd/uplink-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/networkmanager/files/50-mesh.conf b/modules/networkmanager/files/50-mesh.conf new file mode 100644 index 00000000..f8c8e8a2 --- /dev/null +++ b/modules/networkmanager/files/50-mesh.conf @@ -0,0 +1,63 @@ +# Managed by the mesh (module networkmanager). Replaced on every push; edit the +# catalogue instead. +# +# This machine's uplink is NetworkManager's, and this file is the whole of what +# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh, +# and leave the private network's interface alone. Nothing more. The mesh never +# declares a connection profile, an address, a route, a wireless network or its +# credentials — those are joined at the machine, by the person using it, and +# the link they make is the only channel the mesh reaches this machine over. A +# push that got a link wrong could not be undone by the next one. +# +# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the +# operator keeps in this directory. NetworkManager reads the files here sorted by +# name and a later one wins a key it sets again — so a file of the operator's +# that sorts after this one (any name starting with a letter does) and sets dns= +# or unmanaged-devices= overrides it. That is the operator's to decide, and the +# reason this file sets nothing but the two keys it must. +# +# NetworkManager itself is the machine's: the mesh never starts, stops, enables +# or disables it (its service is declared with no state), because stopping it +# takes every link down, this machine's channel to the mesh included — and a +# module unassigned by mistake must not be able to do that. When this file +# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which +# re-reads its configuration — NetworkManager(8)), never restarted. + +[main] +# The resolver file is the mesh's: this module writes /etc/resolv.conf itself, +# listing the mesh's resolvers (novox/hq ADR 0223). Without this line +# NetworkManager rewrites that file on +# every connectivity change — every network joined, every lease renewed — +# and the mesh's resolver is silently replaced while every surface of the mesh +# still reads green. none: "NetworkManager will not modify resolv.conf. This +# implies rc-manager unmanaged" (NetworkManager.conf(5), 1.58). On an adopted +# machine the predecessor wrote the same line in a file of its own; both say one +# thing, and the predecessor's is retired by hand after the take. +# +# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file): +# it writes its own header and composes the options line itself, so it cannot +# write the mesh's file byte for byte, and the three uplink modules would write +# three different files for one fact. dns=none, and the file declared beside it. +dns=none + +[keyfile] +# mesh0 is the private network's interface: the mesh brings it up and the mesh +# alone configures it. A manager that considers every interface its own could +# try to configure it, or tear it down on a profile change. +# +# unmanaged-devices rather than a [device-mesh0] section with managed=0, because +# NetworkManager.conf(5) says a device unmanaged by this key "is strictly +# unmanaged and cannot be overruled by using the API like nmcli device set +# $IFNAME managed yes", while device*.managed "can be overruled at runtime via +# D-Bus". The same page adds that device*.managed "may be a better choice" for +# exactly those reasons — for an interface the operator might want to hand back +# at runtime. For the mesh's own interface, strict is the point. +# +# += rather than =: the same page documents appending to a list-valued key set +# earlier ("plugins+=another-plugin") as an extension of its key file format, +# and unmanaged-devices is a device list. = would replace whatever devices the +# operator already keeps NetworkManager away from; += adds this one to them +# (novox/hq ADR 0102: a list is added to, never replaced). A file of the +# operator's read after this one that sets the key with = replaces it again; +# that is the operator's to decide. +unmanaged-devices+=interface-name:mesh0 diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json index 6d0cbaed..68ace345 100644 --- a/modules/networkmanager/module.json +++ b/modules/networkmanager/module.json @@ -32,6 +32,11 @@ "loads": [ "uplink-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -52,7 +57,7 @@ "type": "file", "path": "/etc/NetworkManager/conf.d/50-mesh.conf", "mode": "0644", - "content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: this module writes /etc/resolv.conf itself,\n# listing the mesh's resolvers (novox/hq ADR 0223). Without this line\n# NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\n#\n# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file):\n# it writes its own header and composes the options line itself, so it cannot\n# write the mesh's file byte for byte, and the three uplink modules would write\n# three different files for one fact. dns=none, and the file declared beside it.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n" + "template": "files/50-mesh.conf" }, { "id": "service", diff --git a/modules/nextcloud/files/config.json b/modules/nextcloud/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/nextcloud/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/nextcloud/files/server.env b/modules/nextcloud/files/server.env new file mode 100644 index 00000000..df6e20f6 --- /dev/null +++ b/modules/nextcloud/files/server.env @@ -0,0 +1,14 @@ +POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +POSTGRES_DB=${bound:postgres-database:as} +POSTGRES_USER=${bound:postgres-database:as} +POSTGRES_PASSWORD=${secret:postgres-database} +NEXTCLOUD_ADMIN_USER=mesh-admin +NEXTCLOUD_ADMIN_PASSWORD=${secret:admin} +OBJECTSTORE_S3_HOST=${bound:s3-bucket:at} +OBJECTSTORE_S3_PORT=${bound:s3-bucket:port} +OBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket} +OBJECTSTORE_S3_KEY=${bound:s3-bucket:as} +OBJECTSTORE_S3_SECRET=${secret:s3-bucket} +OBJECTSTORE_S3_SSL=false +OBJECTSTORE_S3_USEPATH_STYLE=true +OBJECTSTORE_S3_REGION=${bound:s3-bucket:region} diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 4d648e01..c7d32627 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -54,6 +54,26 @@ } ] }, + "config": { + "container": { + "type": "string", + "description": "The name of the container the module's tools run Nextcloud's occ in; empty is nextcloud.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Nextcloud's sharing API at, as a URL; empty is this machine's Nextcloud on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Nextcloud account the module's tools act as; empty is admin.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -72,7 +92,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket}\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n" + "template": "files/server.env" }, { "id": "html", @@ -83,6 +103,14 @@ { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/status.php", + "status": 200, + "body": "\"installed\":true", + "needs": "postgres-database" + }, "name": "nextcloud", "image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08", "env-file": [ @@ -101,7 +129,8 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["container", "url", "user"], "merge": "json" } ], @@ -125,6 +154,11 @@ "MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin", "MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/nfs-server/cmd/nfs-server/manifest_test.go b/modules/nfs-server/cmd/nfs-server/manifest_test.go index f9e12319..d43d5776 100644 --- a/modules/nfs-server/cmd/nfs-server/manifest_test.go +++ b/modules/nfs-server/cmd/nfs-server/manifest_test.go @@ -22,8 +22,8 @@ type manifest struct { Protocol string `json:"protocol"` From string `json:"from"` } `json:"listens"` - Settings map[string]any `json:"settings"` - Resources []map[string]any `json:"resources"` + Config map[string]map[string]any `json:"config"` + Resources []map[string]any `json:"resources"` } func readManifest(t *testing.T) (manifest, string) { @@ -83,28 +83,34 @@ func TestItListensOnTwoThousandFortyNineFromTheMeshOnlyAndProvidesTheShare(t *te t.Errorf("the module opens %s", port) } } - if !strings.Contains(raw, "vers3=n") { + nfsConf := "" + for _, r := range m.Resources { + if r["id"] == "nfs-conf" { + nfsConf = templateText(t, r) + } + } + if !strings.Contains(nfsConf, "vers3=n") { t.Errorf("NFS version 3 is not switched off, so 2049 alone would not do") } } // The definition names no folder, no machine and no person (novox/hq ADR 0112): the shares are the -// assignment's setting, and the range the mesh's. +// assignment's config value, required and with no default (ADR 0348 §4), and the range the mesh's. func TestTheDefinitionNamesNoFolderNoMachineAndGivesTheSharesNoDefault(t *testing.T) { m, raw := readManifest(t) if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen)\b|/home/|/storage|/srv/`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" { t.Errorf("the manifest says %q", hit) } - if _, declared := m.Settings["shares"]; declared { - t.Errorf("the shares are the operator's value and have no default (ADR 0262)") + if s := m.Config["shares"]; s["required"] != true || s["default"] != nil || s["type"] != "string" { + t.Errorf("the shares are the operator's value: required, with no default (ADR 0348 §4): %v", s) } var config string for _, r := range m.Resources { if r["id"] == "config" { - config, _ = r["content"].(string) + config = templateText(t, r) } } - if !strings.Contains(config, "shares=${setting:shares}") || !strings.Contains(config, "range=${machine:mesh-range}") { + if !strings.Contains(config, "shares=${config:shares}") || !strings.Contains(config, "range=${machine:mesh-range}") { t.Errorf("the config file: %q", config) } // Every long-running resource says how it is ready (ADR 0240 rule 8). @@ -124,9 +130,9 @@ func TestTheDefinitionNamesNoFolderNoMachineAndGivesTheSharesNoDefault(t *testin func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.T) { m, raw := readManifest(t) var full struct { - Reads []string `json:"reads"` - Facts map[string]map[string]string `json:"facts"` - Settings map[string]map[string]any `json:"settings"` + Reads []string `json:"reads"` + Facts map[string]map[string]string `json:"facts"` + Config map[string]map[string]any `json:"config"` } if err := json.Unmarshal([]byte(raw), &full); err != nil { t.Fatal(err) @@ -138,7 +144,7 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing. if roster["path"] != RosterFile || !strings.Contains(roster["template"], "{{range .Machines}}{{.Name}} {{.Address}}") { t.Errorf("the roster fact: %v", roster) } - if g := full.Settings["grants"]; g["kind"] != "preference" || g["default"] != "none" { + if g := full.Config["grants"]; g["type"] != "string" || g["default"] != "none" || g["trusted"] != true { t.Errorf("grants: %v", g) } order := map[string]int{} @@ -146,13 +152,13 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing. for i, r := range m.Resources { order[r["id"].(string)] = i if r["id"] == "config" { - config, _ = r["content"].(string) + config = templateText(t, r) } if r["path"] == WantsDir && r["owner"] != "${machine:account}" { t.Errorf("the tools, as the operator account, cannot write the wants: %v", r) } } - if !strings.Contains(config, "grants=${setting:grants}") { + if !strings.Contains(config, "grants=${config:grants}") { t.Errorf("the config lacks the grants: %q", config) } if _, ok := order["wants-dir"]; !ok { diff --git a/modules/nfs-server/cmd/nfs-server/templates_test.go b/modules/nfs-server/cmd/nfs-server/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/nfs-server/cmd/nfs-server/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/nfs-server/files/50-mesh.conf b/modules/nfs-server/files/50-mesh.conf new file mode 100644 index 00000000..2640a8b2 --- /dev/null +++ b/modules/nfs-server/files/50-mesh.conf @@ -0,0 +1,12 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of +# the operator's that sorts after this one overrides it, and is theirs. +# +# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more +# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open. +[nfsd] +vers2=n +vers3=n +vers4=y +vers4.0=n +vers4.1=y +vers4.2=y diff --git a/modules/nfs-server/files/shares.conf b/modules/nfs-server/files/shares.conf new file mode 100644 index 00000000..f25f0261 --- /dev/null +++ b/modules/nfs-server/files/shares.conf @@ -0,0 +1,10 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment. +# Replaced on every push; change the `shares` setting, never this file. +# +# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…], +# which nodes may mount each and how. The module's process exports a share to a node's private address +# only when it is granted here and that node's mounts module wants it, every client mapped to the +# folder's owner, and only to addresses inside the range below. +shares=${config:shares} +grants=${config:grants} +range=${machine:mesh-range} diff --git a/modules/nfs-server/module.json b/modules/nfs-server/module.json index d67cddde..70b7f924 100644 --- a/modules/nfs-server/module.json +++ b/modules/nfs-server/module.json @@ -51,13 +51,20 @@ "seat:mesh-controller.seats", "seat:mesh-controller.data" ], - "settings": { - "grants": { - "kind": "preference", - "default": "none", - "why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants" - } - }, + "config": { + "shares": { + "type": "string", + "description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).", + "trusted": true, + "required": true + }, + "grants": { + "type": "string", + "description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).", + "trusted": true, + "default": "none" + } + }, "facts": { "machines": { "path": "/etc/nfs-server/machines", @@ -88,7 +95,7 @@ "type": "file", "path": "/etc/nfs.conf.d/50-mesh.conf", "mode": "0644", - "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of\n# the operator's that sorts after this one overrides it, and is theirs.\n#\n# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more\n# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open.\n[nfsd]\nvers2=n\nvers3=n\nvers4=y\nvers4.0=n\nvers4.1=y\nvers4.2=y\n" + "template": "files/50-mesh.conf" }, { "id": "run-dir", @@ -154,7 +161,7 @@ "type": "file", "path": "/etc/nfs-server/shares.conf", "mode": "0644", - "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n" + "template": "files/shares.conf" } ], "build": { @@ -169,6 +176,11 @@ "loads": [ "nfs-server" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/nftables/files/mesh-filter.service b/modules/nftables/files/mesh-filter.service new file mode 100644 index 00000000..5a6ccedb --- /dev/null +++ b/modules/nftables/files/mesh-filter.service @@ -0,0 +1,14 @@ +[Unit] +Description=The mesh's packet filter, derived from what is assigned to this node +Wants=network-pre.target +Before=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/nftables.conf +ExecReload=nft -f /etc/nftables.conf +ExecStop=nft delete table inet mesh + +[Install] +WantedBy=multi-user.target diff --git a/modules/nftables/files/mesh.conf b/modules/nftables/files/mesh.conf new file mode 100644 index 00000000..f1a7bb0d --- /dev/null +++ b/modules/nftables/files/mesh.conf @@ -0,0 +1,5 @@ +# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset +# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it. +[Service] +ExecStop= +ExecStop=nft delete table inet mesh diff --git a/modules/nftables/module.json b/modules/nftables/module.json index 2a80c58b..b7b0c83f 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -37,14 +37,14 @@ "id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service", - "content": "[Unit]\nDescription=The mesh's packet filter, derived from what is assigned to this node\nWants=network-pre.target\nBefore=network-pre.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=nft -f /etc/nftables.conf\nExecReload=nft -f /etc/nftables.conf\nExecStop=nft delete table inet mesh\n\n[Install]\nWantedBy=multi-user.target\n", + "template": "files/mesh-filter.service", "mode": "0644" }, { "id": "stock-unit-stop", "type": "file", "path": "/etc/systemd/system/nftables.service.d/mesh.conf", - "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", + "template": "files/mesh.conf", "mode": "0644" }, { @@ -80,6 +80,11 @@ "entrypoints": [ "tools/index.js" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/node-env/files/50-mesh.conf b/modules/node-env/files/50-mesh.conf new file mode 100644 index 00000000..3aebc873 --- /dev/null +++ b/modules/node-env/files/50-mesh.conf @@ -0,0 +1,4 @@ +# The operator account's environment for its service manager and graphical session, generated by +# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push. +# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax. +${collected:environment:systemd} \ No newline at end of file diff --git a/modules/node-env/files/environment.sh b/modules/node-env/files/environment.sh new file mode 100644 index 00000000..c28adf34 --- /dev/null +++ b/modules/node-env/files/environment.sh @@ -0,0 +1,5 @@ +# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203). +# Do not edit: this file is replaced at every push. Every line names the module that contributed it. +# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a +# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines. +${collected:environment:posix} \ No newline at end of file diff --git a/modules/node-env/module.json b/modules/node-env/module.json index 43b27e88..a933578c 100644 --- a/modules/node-env/module.json +++ b/modules/node-env/module.json @@ -1,6 +1,7 @@ { "module": "node-env", "version": "1", + "moved": true, "claims": [ { "name": "node-environment", @@ -28,7 +29,7 @@ "path": "${machine:account-home}/.config/mesh/environment.sh", "owner": "${machine:account}", "mode": "0644", - "content": "# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203).\n# Do not edit: this file is replaced at every push. Every line names the module that contributed it.\n# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a\n# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines.\n${environment:posix}" + "template": "files/environment.sh" }, { "id": "systemd", @@ -36,7 +37,16 @@ "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# The operator account's environment for its service manager and graphical session, generated by\n# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push.\n# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax.\n${environment:systemd}" + "template": "files/50-mesh.conf" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/nodered/files/config.json b/modules/nodered/files/config.json new file mode 100644 index 00000000..a396fbd1 --- /dev/null +++ b/modules/nodered/files/config.json @@ -0,0 +1,3 @@ +{ + "token": "${secret:api-token}" +} diff --git a/modules/nodered/files/mqtt.env b/modules/nodered/files/mqtt.env new file mode 100644 index 00000000..5331f3cf --- /dev/null +++ b/modules/nodered/files/mqtt.env @@ -0,0 +1,4 @@ +MESH_NODERED_URL=http://127.0.0.1:${port:1880} +MESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/modules/nodered/files/settings.js b/modules/nodered/files/settings.js new file mode 100644 index 00000000..0d0f3515 --- /dev/null +++ b/modules/nodered/files/settings.js @@ -0,0 +1,48 @@ +// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change +// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and +// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user +// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this. +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const ADMIN_PASSWORD = "${secret:admin}"; +const API_TOKEN = "${secret:api-token}"; +const ADMIN = { username: "admin", permissions: "*" }; + +const settings = JSON.parse(fs.readFileSync(path.join(__dirname, "settings.json"), "utf8")); +// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the +// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the +// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for. +if (settings.timeZone) process.env.TZ = settings.timeZone; +delete settings.timeZone; +delete settings.endpoints; +delete settings.mqtt; +delete settings.topics; + +function same(a, b) { + const x = crypto.createHash("sha256").update(String(a)).digest(); + const y = crypto.createHash("sha256").update(String(b)).digest(); + return crypto.timingSafeEqual(x, y); +} + +// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its +// settings held, so the password people already use keeps working. +function passwordMatches(given) { + if (/^\$2[aby]\$\d\d\$/.test(ADMIN_PASSWORD)) return require("bcryptjs").compare(String(given), ADMIN_PASSWORD); + return Promise.resolve(same(given, ADMIN_PASSWORD)); +} + +module.exports = Object.assign(settings, { + uiPort: 1880, + adminAuth: { + type: "credentials", + users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null), + authenticate: (username, password) => + username === ADMIN.username + ? passwordMatches(password).then((ok) => (ok ? ADMIN : null)) + : Promise.resolve(null), + // The module's own tools call the admin API with this bearer token. + tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: "mesh", permissions: "*" } : null), + }, +}); diff --git a/modules/nodered/files/settings.json b/modules/nodered/files/settings.json new file mode 100644 index 00000000..93cf1572 --- /dev/null +++ b/modules/nodered/files/settings.json @@ -0,0 +1,14 @@ +{ + "flowFile": "flows.json", + "flowFilePretty": true, + "diagnostics": { "enabled": true, "ui": true }, + "runtimeState": { "enabled": false, "ui": false }, + "logging": { "console": { "level": "info", "metrics": false, "audit": false } }, + "exportGlobalContextKeys": false, + "externalModules": {}, + "editorTheme": { "projects": { "enabled": false } }, + "functionExternalModules": true, + "debugMaxLength": 1000, + "mqttReconnectTime": 15000, + "serialReconnectTime": 15000 +} diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 3bd4a552..48c65dd0 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -36,6 +36,108 @@ } ] }, + "config": { + "flow-file": { + "type": "string", + "description": "Node-RED's flowFile setting: the file in the user directory the flows are kept in.", + "trusted": true, + "default": "flows.json" + }, + "flow-file-pretty": { + "type": "boolean", + "description": "Node-RED's flowFilePretty setting: whether the flows file is written indented.", + "trusted": true, + "default": true + }, + "diagnostics": { + "type": "object", + "description": "Node-RED's diagnostics setting: whether its diagnostics report is on, and shown in the editor.", + "trusted": true, + "default": { + "enabled": true, + "ui": true + } + }, + "runtime-state": { + "type": "object", + "description": "Node-RED's runtimeState setting: whether flows may be started and stopped from the editor.", + "trusted": true, + "default": { + "enabled": false, + "ui": false + } + }, + "logging": { + "type": "object", + "description": "Node-RED's logging setting: the console log's level, and whether metrics and audit events are logged.", + "trusted": true, + "default": { + "console": { + "level": "info", + "metrics": false, + "audit": false + } + } + }, + "export-global-context-keys": { + "type": "boolean", + "description": "Node-RED's exportGlobalContextKeys setting: whether a function node may list the global context's keys.", + "trusted": true, + "default": false + }, + "external-modules": { + "type": "object", + "description": "Node-RED's externalModules setting: which modules the editor may install, and from where.", + "trusted": true, + "default": {} + }, + "editor-theme": { + "type": "object", + "description": "Node-RED's editorTheme setting: the editor's look and features, projects among them.", + "trusted": true, + "default": { + "projects": { + "enabled": false + } + } + }, + "function-external-modules": { + "type": "boolean", + "description": "Node-RED's functionExternalModules setting: whether a function node may load modules of its own.", + "trusted": true, + "default": true + }, + "debug-max-length": { + "type": "number", + "description": "Node-RED's debugMaxLength setting: the most characters a debug message shows, a count.", + "trusted": true, + "default": 1000 + }, + "mqtt-reconnect-time": { + "type": "number", + "description": "Node-RED's mqttReconnectTime setting: how long an MQTT node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "serial-reconnect-time": { + "type": "number", + "description": "Node-RED's serialReconnectTime setting: how long a serial node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "mqtt": { + "type": "object", + "description": "The broker nodes the module's MQTT step keeps pointed at the mesh's broker, as {\"brokers\": [their ids]}; empty, none.", + "trusted": true, + "default": {} + }, + "time-zone": { + "type": "string", + "description": "The time zone the flows schedule and format in, an IANA name such as Europe/Brussels; unset, the container's UTC.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", @@ -66,7 +168,7 @@ "path": "${dir:state}/settings.js", "mode": "0600", "owner": "1000:1000", - "content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the\n// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\ndelete settings.mqtt;\ndelete settings.topics;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n" + "template": "files/settings.js" }, { "id": "settings", @@ -75,7 +177,8 @@ "mode": "0600", "owner": "1000:1000", "merge": "json", - "content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n" + "takes-config": [{"key": "flow-file", "as": "flowFile"}, {"key": "flow-file-pretty", "as": "flowFilePretty"}, "diagnostics", {"key": "runtime-state", "as": "runtimeState"}, "logging", {"key": "export-global-context-keys", "as": "exportGlobalContextKeys"}, {"key": "external-modules", "as": "externalModules"}, {"key": "editor-theme", "as": "editorTheme"}, {"key": "function-external-modules", "as": "functionExternalModules"}, {"key": "debug-max-length", "as": "debugMaxLength"}, {"key": "mqtt-reconnect-time", "as": "mqttReconnectTime"}, {"key": "serial-reconnect-time", "as": "serialReconnectTime"}, "mqtt", {"key": "time-zone", "as": "timeZone"}], + "template": "files/settings.json" }, { "id": "server", @@ -110,14 +213,14 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"token\": \"${secret:api-token}\"\n}\n" + "template": "files/config.json" }, { "id": "mqtt-env", "type": "file", "path": "${dir:state}/mqtt.env", "mode": "0600", - "content": "MESH_NODERED_URL=http://127.0.0.1:${port:1880}\nMESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n" + "template": "files/mqtt.env" }, { "id": "mqtt", @@ -179,6 +282,11 @@ "MESH_NODERED_URL": "http://127.0.0.1:${port:1880}", "MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/only-office/files/server.env b/modules/only-office/files/server.env new file mode 100644 index 00000000..19fab119 --- /dev/null +++ b/modules/only-office/files/server.env @@ -0,0 +1,5 @@ +JWT_ENABLED=true +JWT_SECRET=${secret:jwt} +JWT_HEADER=Authorization +JWT_IN_BODY=true +ALLOW_PRIVATE_IP_ADDRESS=true diff --git a/modules/only-office/module.json b/modules/only-office/module.json index 49f10efd..ac0eed75 100644 --- a/modules/only-office/module.json +++ b/modules/only-office/module.json @@ -87,7 +87,7 @@ "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "JWT_ENABLED=true\nJWT_SECRET=${secret:jwt}\nJWT_HEADER=Authorization\nJWT_IN_BODY=true\nALLOW_PRIVATE_IP_ADDRESS=true\n" + "template": "files/server.env" }, { "id": "logs", @@ -152,5 +152,14 @@ ], "secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/pacman/cmd/pacman-tools/manifest_test.go b/modules/pacman/cmd/pacman-tools/manifest_test.go index 4b461587..b55f6908 100644 --- a/modules/pacman/cmd/pacman-tools/manifest_test.go +++ b/modules/pacman/cmd/pacman-tools/manifest_test.go @@ -34,7 +34,7 @@ func TestItHoldsThePackageManagerSeatAndDeclaresItsPackage(t *testing.T) { func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing.T) { f := manifest(t).resource(t, "config") - content := f["content"].(string) + content := templateText(t, f) if f["path"] != "/etc/pacman.conf" || f["into"] != nil || !strings.HasPrefix(content, MeshHeader) { t.Fatalf("%v", f) } @@ -75,7 +75,7 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing } func TestReflectorWritesTheListPacmanReads(t *testing.T) { - content := manifest(t).resource(t, "mirrors")["content"].(string) + content := templateText(t, manifest(t).resource(t, "mirrors")) opts := map[string]string{} for _, l := range strings.Split(content, "\n") { if l == "" || strings.HasPrefix(l, "#") { diff --git a/modules/pacman/cmd/pacman-tools/templates_test.go b/modules/pacman/cmd/pacman-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/pacman/files/pacman.conf b/modules/pacman/files/pacman.conf new file mode 100644 index 00000000..0390e254 --- /dev/null +++ b/modules/pacman/files/pacman.conf @@ -0,0 +1,29 @@ +# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written +# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by +# the host. Owned whole because [options] cannot take a block by appending: anything added at the end +# of the file lands in the last repository's section. +# +# The repositories are the union of what the machines had enabled when the module was written +# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a +# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged +# alpm user, which pacman 7 creates. + +[options] +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +Color +VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[multilib] +Include = /etc/pacman.d/mirrorlist diff --git a/modules/pacman/files/reflector.conf b/modules/pacman/files/reflector.conf new file mode 100644 index 00000000..eb9e731b --- /dev/null +++ b/modules/pacman/files/reflector.conf @@ -0,0 +1,8 @@ +# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly, +# through reflector.timer. Written whole at every push. Before the module, every machine's list was +# generated once and never again. +--save /etc/pacman.d/mirrorlist +--protocol https +--country Belgium,Netherlands,Luxembourg,Germany,France +--latest 20 +--sort rate diff --git a/modules/pacman/module.json b/modules/pacman/module.json index 98ef2440..746c8c5a 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -39,7 +39,7 @@ "type": "file", "path": "/etc/pacman.conf", "mode": "0644", - "content": "# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written\n# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by\n# the host. Owned whole because [options] cannot take a block by appending: anything added at the end\n# of the file lands in the last repository's section.\n#\n# The repositories are the union of what the machines had enabled when the module was written\n# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a\n# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged\n# alpm user, which pacman 7 creates.\n\n[options]\nHoldPkg = pacman glibc\nArchitecture = auto\nCheckSpace\nColor\nVerbosePkgLists\nParallelDownloads = 5\nDownloadUser = alpm\nSigLevel = Required DatabaseOptional\nLocalFileSigLevel = Optional\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n" + "template": "files/pacman.conf" }, { "id": "contrib", @@ -56,7 +56,7 @@ "type": "file", "path": "/etc/xdg/reflector/reflector.conf", "mode": "0644", - "content": "# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly,\n# through reflector.timer. Written whole at every push. Before the module, every machine's list was\n# generated once and never again.\n--save /etc/pacman.d/mirrorlist\n--protocol https\n--country Belgium,Netherlands,Luxembourg,Germany,France\n--latest 20\n--sort rate\n" + "template": "files/reflector.conf" }, { "id": "mirror-refresh", @@ -85,6 +85,11 @@ "loads": [ "pacman-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/picom/cmd/picom-tools/manifest_helpers_test.go b/modules/picom/cmd/picom-tools/manifest_helpers_test.go index a098c63c..a652d238 100644 --- a/modules/picom/cmd/picom-tools/manifest_helpers_test.go +++ b/modules/picom/cmd/picom-tools/manifest_helpers_test.go @@ -12,27 +12,35 @@ import ( // every desktop module that carries it. type manifest struct { - Module string `json:"module"` - Version string `json:"version"` - Capabilities []string `json:"capabilities"` - Requires []string `json:"requires"` - Claims []claim `json:"claims"` - Seats []any `json:"seats"` - Tools []string `json:"tools"` - Settings map[string]setting `json:"settings"` - Environment *environment `json:"environment"` - Shell []shellCode `json:"shell"` - Resources []map[string]any `json:"resources"` + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Config map[string]config `json:"config"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } -// setting is one key's default, as the definition gives it (novox/hq ADR 0262). -type setting struct { - Kind string `json:"kind"` - Default any `json:"default"` - Why string `json:"why"` +// config is one config value as the definition declares it (novox/hq ADR 0348 §4). +type config struct { + Type string `json:"type"` + Description string `json:"description"` + Enum []any `json:"enum"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Pattern string `json:"pattern"` + Trusted bool `json:"trusted"` + Predefined string `json:"predefined"` + Default any `json:"default"` + Required bool `json:"required"` + Runtime bool `json:"runtime"` } type claim struct { @@ -91,26 +99,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { diff --git a/modules/picom/cmd/picom-tools/manifest_test.go b/modules/picom/cmd/picom-tools/manifest_test.go index b82d8ccc..5c6a397d 100644 --- a/modules/picom/cmd/picom-tools/manifest_test.go +++ b/modules/picom/cmd/picom-tools/manifest_test.go @@ -28,7 +28,7 @@ func TestItClaimsTheCompositorSeatAndRequiresTheXDisplay(t *testing.T) { func TestItOwnsItsConfigurationAsWrittenInTheModule(t *testing.T) { m := readManifest(t) - m.sameAsSource(t, "configuration", "files/picom.conf") + m.fromTemplate(t, "configuration", "files/picom.conf") if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/picom/picom.conf" { t.Fatalf("path: %v", p) } @@ -62,16 +62,19 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { // The backend and damage tracking are each machine's (novox/hq research 037: the backend depends on the // machine's graphics driver; issue 345: a window left unpainted under glx is the first suspect). Both -// are settings whose defaults are what every machine ran before, so the file filled with them is today's. -func TestTheBackendAndDamageTrackingAreSettingsWithTodaysDefaults(t *testing.T) { +// are config values whose defaults are what every machine ran before, so the file filled with them is today's +// (novox/hq ADR 0348 §4): the backend a string of picom's backends, damage tracking a boolean, both trusted +// as the file is. +func TestTheBackendAndDamageTrackingAreConfigValuesWithTodaysDefaults(t *testing.T) { m := readManifest(t) - b, d := m.Settings["backend"], m.Settings["use-damage"] - if b.Kind != "preference" || b.Default != "glx" || b.Why == "" || d.Kind != "preference" || d.Default != true || d.Why == "" { - t.Fatalf("settings: %+v", m.Settings) + b, d := m.Config["backend"], m.Config["use-damage"] + if b.Type != "string" || b.Default != "glx" || b.Description == "" || !b.Trusted || len(b.Enum) == 0 || + d.Type != "boolean" || d.Default != true || d.Description == "" || !d.Trusted { + t.Fatalf("config: %+v", m.Config) } r := m.resource(t, "configuration") - c := r["content"].(string) - for _, want := range []string{`backend = "${setting:backend}";`, "use-damage = ${setting:use-damage};"} { + c := m.text(t, "configuration") + for _, want := range []string{`backend = "${config:backend}";`, "use-damage = ${config:use-damage};"} { if !strings.Contains(c, want) { t.Errorf("the file asks for %q", want) } @@ -88,7 +91,12 @@ func TestTheBackendAndDamageTrackingAreSettingsWithTodaysDefaults(t *testing.T) } } -// withDefaults fills the file's settings with their defaults, as a machine without its own values gets it. +// withDefaults fills the file's config values with their defaults, as a machine without its own values gets it. func withDefaults(c string) string { - return strings.NewReplacer("${setting:backend}", "glx", "${setting:use-damage}", "true").Replace(c) + return strings.NewReplacer("${config:backend}", "glx", "${config:use-damage}", "true").Replace(c) +} + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) } diff --git a/modules/picom/files/picom.conf b/modules/picom/files/picom.conf index 1c49876f..c6b85551 100644 --- a/modules/picom/files/picom.conf +++ b/modules/picom/files/picom.conf @@ -7,8 +7,8 @@ # machine ran before: glx, and picom's own default of repainting only what changed. xrender, or # use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is # left unpainted or stale. -backend = "${setting:backend}"; -use-damage = ${setting:use-damage}; +backend = "${config:backend}"; +use-damage = ${config:use-damage}; vsync = true; fading = true; diff --git a/modules/picom/module.json b/modules/picom/module.json index 99d95644..2f747784 100644 --- a/modules/picom/module.json +++ b/modules/picom/module.json @@ -13,18 +13,26 @@ "scope": "node" } ], - "settings": { - "backend": { - "kind": "preference", - "default": "glx", - "why": "glx draws with the graphics card and has served every machine so far; xrender draws on the CPU and is what to try where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345)" + "config": { + "backend": { + "type": "string", + "description": "How picom draws: glx with the graphics card, which has served every machine so far, or xrender on the CPU where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345); xr_glx_hybrid and egl are picom's other backends.", + "enum": [ + "glx", + "xrender", + "xr_glx_hybrid", + "egl" + ], + "trusted": true, + "default": "glx" + }, + "use-damage": { + "type": "boolean", + "description": "Whether picom repaints only what changed (true, picom's own default) or the whole screen each frame (false), which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345).", + "trusted": true, + "default": true + } }, - "use-damage": { - "kind": "preference", - "default": true, - "why": "true repaints only what changed, picom's own default; false repaints the whole screen each frame, which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345)" - } - }, "tools": [ "picom_restart", "picom_rules", @@ -55,7 +63,7 @@ "path": "${machine:account-home}/.config/picom/picom.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\n# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research\n# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every\n# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or\n# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is\n# left unpainted or stale.\nbackend = \"${setting:backend}\";\nuse-damage = ${setting:use-damage};\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n" + "template": "files/picom.conf" } ], "build": { @@ -70,6 +78,11 @@ "loads": [ "picom-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go b/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go index 5baaba42..aef16b5b 100644 --- a/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go +++ b/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go @@ -25,11 +25,13 @@ type manifest struct { Claims []any `json:"claims"` Seats []any `json:"seats"` Shell []any `json:"shell"` - Contributions []struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` - } `json:"contributions"` + Moved bool `json:"moved"` + Contributions []any `json:"contributions"` + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + Template string `json:"template"` + } `json:"items"` Environment any `json:"environment"` Build struct { Artifacts []map[string]any `json:"artifacts"` @@ -69,7 +71,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { for name := range served { t.Errorf("the bundle serves %s, which module.json does not list", name) } - if len(m.Build.Artifacts) != 1 { + if len(m.Build.Artifacts) != 2 { // the tools bundle and the archive of its template t.Fatalf("%v", m.Build.Artifacts) } b := m.Build.Artifacts[0] @@ -93,11 +95,8 @@ func TestItDeclaresNoPackageAndContributesTheTraysOneStart(t *testing.T) { if m.Resources != nil || m.Capabilities != nil || m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil { t.Fatal("no package (outside the official repositories), no seat, no environment, no xinitrc start") } - if len(m.Contributions) != 1 || m.Contributions[0].Seat != "node-display-session" || m.Contributions[0].Kind != "config" { - t.Fatalf("%+v", m.Contributions) - } var code []string - for _, l := range strings.Split(m.Contributions[0].Content, "\n") { + for _, l := range strings.Split(displayLines(t, m), "\n") { if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") { code = append(code, l) } @@ -121,8 +120,23 @@ func TestTheContributionParsesInI3(t *testing.T) { } m, _ := readManifest(t) path := filepath.Join(t.TempDir(), "config") - os.WriteFile(path, []byte("set $mod Mod4\n"+m.Contributions[0].Content), 0o644) + os.WriteFile(path, []byte("set $mod Mod4\n"+displayLines(t, m)), 0o644) if out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput(); err != nil || strings.Contains(string(out), "ERROR") { t.Fatalf("i3 -C: %v %s", err, out) } } + +// displayLines is the module's lines for the window manager: the text of the template of its one item of the +// collection contribution:node-display-session:config (design 57 L3, issue 848). +func displayLines(t *testing.T, m manifest) string { + t.Helper() + if !m.Moved || len(m.Contributions) != 0 || len(m.Items) != 1 || + m.Items[0].Collection != "contribution:node-display-session:config" { + t.Fatalf("moved %v, contributions %v, items %+v", m.Moved, m.Contributions, m.Items) + } + text, err := os.ReadFile(filepath.Join("..", "..", m.Items[0].Template)) + if err != nil { + t.Fatal(err) + } + return string(text) +} diff --git a/modules/polychromatic/files/display-session b/modules/polychromatic/files/display-session new file mode 100644 index 00000000..bbdbd6f2 --- /dev/null +++ b/modules/polychromatic/files/display-session @@ -0,0 +1,6 @@ +# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh: +# replaced at every push. This line is the tray's one start, at the session's start (exec, not +# exec_always, so a reload starts nothing). The package's own login helper starts it too while the +# application's setting "Start the tray applet when I log on" is ticked; polychromatic_check names +# that as a second start. +exec --no-startup-id polychromatic-tray-applet diff --git a/modules/polychromatic/module.json b/modules/polychromatic/module.json index eacb0bfe..4d6ba4b3 100644 --- a/modules/polychromatic/module.json +++ b/modules/polychromatic/module.json @@ -1,6 +1,7 @@ { "module": "polychromatic", "version": "1", + "moved": true, "requires": [ "x11-display" ], @@ -9,11 +10,11 @@ "polychromatic_restart", "polychromatic_check" ], - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" } ], "build": { @@ -28,6 +29,11 @@ "loads": [ "polychromatic-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/postgres/cmd/postgres-provider/main.go b/modules/postgres/cmd/postgres-provider/main.go index af26205c..4caf857f 100644 --- a/modules/postgres/cmd/postgres-provider/main.go +++ b/modules/postgres/cmd/postgres-provider/main.go @@ -33,7 +33,32 @@ func main() { return } var h *Harness - if receives := os.Getenv("MESH_RECEIVES"); receives == "" { + if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" { + // Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its + // machine's client fills the file with; the grant directory is not read. Retirement is the + // harness's, never run, so its guards and its tools stand (issue 780). + h = &Harness{ + Resource: ProvisionName, + Adapter: provisioner{pg: pg, announce: announce}, + Log: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) }, + Announce: func(event string, body map[string]any) { + if err := stdio.Emit(event, body); err != nil { + say("emit %s failed: %v", event, err) + } + }, + Node: os.Getenv("MESH_NODE"), + } + run, p, bus, err := moved(consumers, h) + if err != nil { + say("%v", err) + } else { + go func() { + if err := run.Run(context.Background(), p, bus); err != nil { + say("the provider helper stopped: %v", err) + } + }() + } + } else if receives := os.Getenv("MESH_RECEIVES"); receives == "" { say("MESH_RECEIVES is not set — the provisioner cannot run without it") } else { h = &Harness{ @@ -52,6 +77,15 @@ func main() { } go h.Run(context.Background()) } + // Started on the grant path while the module is to be handed, it restarts itself when its client first fills + // the consumers' file, so the first fill moves it onto the helper without a hand restart (issue 780): `restart-on` + // names a service or a container, and the only one here is the database. + if at := os.Getenv("MESH_CONSUMERS_FILE"); at != "" && handedConsumersFile(os.Getenv, os.Stat) == "" { + go exitWhenFilled(at, os.Stat, 5*time.Second, func() { + say("%s is filled: restarting to answer from it", at) + os.Exit(0) + }) + } go audit() // The store's reader is narrowed to what modules declare readable, at once and every minute, whether // or not the provisioner runs: without a contributions file it is given nothing (novox/hq issue 337). @@ -64,6 +98,31 @@ func main() { } } +// handedConsumersFile is the consumers' file when postgres is handed to the provisioner: the file MESH_CONSUMERS_FILE +// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not +// there, and postgres then reads the grant directory exactly as it always did (issue 780). +func handedConsumersFile(getenv func(string) string, stat func(string) (os.FileInfo, error)) string { + at := getenv("MESH_CONSUMERS_FILE") + if at == "" { + return "" + } + if _, err := stat(at); err != nil { + return "" + } + return at +} + +// exitWhenFilled calls filled once the file is there, asking every so often. +func exitWhenFilled(at string, stat func(string) (os.FileInfo, error), every time.Duration, filled func()) { + for { + if _, err := stat(at); err == nil { + filled() + return + } + time.Sleep(every) + } +} + // announce emits a lifecycle event without letting a broker hiccup fail the provisioning itself. func announce(event string, body map[string]string) { if err := stdio.Emit(event, body); err != nil { diff --git a/modules/postgres/cmd/postgres-provider/moved.go b/modules/postgres/cmd/postgres-provider/moved.go new file mode 100644 index 00000000..4f8e8642 --- /dev/null +++ b/modules/postgres/cmd/postgres-provider/moved.go @@ -0,0 +1,253 @@ +package main + +// postgres handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issue 780): where its consumers file +// is set, postgres runs on the SDK's provider helper instead of the harness reading the controller's grant files. +// +// The helper learns who binds postgres from the plans, composes each consumer's entry and reads its credential +// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks +// here: Make brings the consumer's database and role into being exactly as the harness's adapter does, and Gone +// retires what was kept for a consumer that no longer binds, the reversible way (retire_pg.go): the login is +// locked and marked to delete, nothing is dropped. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// ProvisionName is what postgres provides, as its manifest says. +const ProvisionName = "postgres-database" + +// servedPort is the port its manifest serves (`serves.postgres-database.port`). +const servedPort = 5432 + +// movedRun is postgres on the provider helper, with the harness's own memory and retirement behind it: what is held, +// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's +// Make and Gone and the loop below drive the same state, so the guards are the harness's, not a copy. +type movedRun struct { + h *Harness + + mu sync.Mutex // guards assignments and loaded + assignments map[string][]byte + loaded bool + verified map[string]time.Time +} + +func newMovedRun(h *Harness) *movedRun { + h.mu.Lock() + h.init() + h.mu.Unlock() + return &movedRun{h: h, assignments: map[string][]byte{}, verified: map[string]time.Time{}} +} + +// Make brings one consumer's database and role into being under its login and the vault's credential, as the +// harness does: at once when it is new or changed, and otherwise only when the server no longer holds it, which is +// asked every VerifyEvery. +func (m *movedRun) Make(ctx context.Context, c provider.Consumer) error { + h, e := m.h, c.Entry + if e.User == "" { + return fmt.Errorf("%s has no login to make", e.Key()) + } + if len(c.Credential) == 0 { + return fmt.Errorf("%s has no credential to make its login with", e.Key()) + } + h.mu.Lock() + defer h.mu.Unlock() + as, password := e.User, string(c.Credential) + p := Provision{As: as, Password: password, Values: orEmpty(e.Contributes), Consumer: e.Consumer} + hash := hashOf(as, password, p.Values, nil) + if was, ok := h.applied[as]; ok && was.hash == hash { + if h.Now().Sub(m.verified[as]) < h.VerifyEvery { + return nil + } + m.verified[as] = h.Now() + hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) + held, err := h.Adapter.Holds(hctx, p) + cancel() + if err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + if held { + h.succeeded(as) + return nil + } + h.say("%s: the backend no longer holds it; applying again", as) + } + if err := h.Adapter.Create(ctx, p); err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + h.succeeded(as) + h.applied[as] = appliedEntry{hash: hash, node: e.Consumer} + m.verified[as] = h.Now() + h.reenabled(as, e.Consumer) + return nil +} + +// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is +// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time +// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing: +// the helper is told no, and asks again at its next pass, until the login is retired, or asked for again. +func (m *movedRun) Gone(_ context.Context, e provider.Entry) error { + h := m.h + as := e.User + if as == "" { + return nil // refused: nothing was made for it + } + // The plans as they are now, not as the last retirement pass read them: with none read, nothing is let go. + want, read := m.asked(h.Node) + asked := want[as] + h.mu.Lock() + defer h.mu.Unlock() + _, held := h.applied[as] + _, retired := h.r.retired[as] + switch { + case !read: + case asked: + return nil // another holder of the same login still binds: nothing to retire + case retired, !held && h.r.seeded: + return nil + } + return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+ + "person approves it", as, h.StablePasses, h.StableFor) +} + +// plan is as much of an assignment as is read to learn who binds postgres. +type plan struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Provision string `json:"provision"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + // As is who the consumer is at its provider's end, its slug first (issue 788). + As string `json:"as"` + } `json:"provisions"` + } `json:"provisioning"` +} + +// asked is the logins the plans bind postgres for, and whether they could all be read: a plan that is not the +// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241). +func (m *movedRun) asked(node string) (map[string]bool, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.loaded || len(m.assignments) == 0 { + return nil, false + } + want := map[string]bool{} + for key, raw := range m.assignments { + var a plan + if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key { + return nil, false + } + if a.Provisioning == nil { + continue + } + for _, b := range a.Provisioning.Provisions { + if b.Provider == node && b.ProviderModule == "postgres" && b.Provision == ProvisionName { + as := b.As + if as == "" { + as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none + } + want[as] = true + } + } + } + return want, true +} + +// retire is one pass of the harness's retirement over what the plans bind now. +func (m *movedRun) retire(ctx context.Context) { + h := m.h + want, ok := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + if !ok { + // Not a result: the passes that must agree before anything is retired start again. + h.r.key, h.r.count = "", 0 + return + } + h.seed(ctx, want) + h.retireUnasked(ctx, want) + h.r.lastWant = want + for as := range h.failing { + if !want[as] { + delete(h.failing, as) + } + } + for as := range h.trouble { + if _, held := h.applied[as]; !want[as] && !held { + h.recovered(as, "no longer asked for") + } + } +} + +// watch keeps the plans as the bus holds them, from the first read on. +func (m *movedRun) watch(ctx context.Context, bus provider.Bus) error { + err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) { + m.mu.Lock() + defer m.mu.Unlock() + if value == nil { + delete(m.assignments, key) + } else { + m.assignments[key] = append([]byte(nil), value...) + } + }) + if err == nil { + m.mu.Lock() + m.loaded = true + m.mu.Unlock() + } + return err +} + +// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends. +func (m *movedRun) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error { + if err := m.watch(ctx, bus); err != nil { + return err + } + go func() { + for { + m.retire(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(m.h.Every): + } + } + }() + return p.Run(ctx) +} + +// moved is postgres handed to the provisioner: the helper answering its consumers, and the harness (never run) whose +// retirement and tools it shares. consumersFile is where its machine's client fills its consumers. +func moved(consumersFile string, h *Harness) (*movedRun, *provider.Provider, provider.Bus, error) { + if consumersFile == "" { + return nil, nil, nil, errors.New("no consumers' file") + } + run := newMovedRun(h) + bus := provider.StateBus("postgres") + return run, provider.New(provider.Options{ + Node: h.Node, + Module: "postgres", + Offers: map[string]provider.Offer{ProvisionName: { + Credential: provider.PerConsumer, + Serves: map[string]any{"port": servedPort}, + }}, + Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, + Make: run.Make, + Gone: run.Gone, + Every: h.Every, + Logf: func(f string, a ...any) { h.say(f, a...) }, + }), bus, nil +} diff --git a/modules/postgres/cmd/postgres-provider/moved_test.go b/modules/postgres/cmd/postgres-provider/moved_test.go new file mode 100644 index 00000000..db51aed4 --- /dev/null +++ b/modules/postgres/cmd/postgres-provider/moved_test.go @@ -0,0 +1,358 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the +// consumer contributes (issue 776). +func (b *bucketBus) bind(node, module string, contributes map[string]any) { + plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "postgres", + "contributes": contributes}}}}) + if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil { + panic(err) + } +} + +// consumersFile writes the file the machine's client fills: one credential per consumer. +func consumersFile(t *testing.T, bound map[[2]string]string) string { + var list []provider.ConsumerCredential + for k, password := range bound { + list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1], + Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password}) + } + raw, _ := json.Marshal(list) + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, raw, 0o600); err != nil { + t.Fatal(err) + } + return at +} + +// movedWorld is postgres on the helper over an in-memory bus, with a recorder for a server and a clock to move. +type movedWorld struct { + t *testing.T + a *recorder + bus *bucketBus + h *Harness + run *movedRun + p *provider.Provider + now time.Time + file string +} + +func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld { + w := &movedWorld{t: t, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}}, + now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + w.file = consumersFile(t, bound) + w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now }, + Log: func(string, ...any) {}} + var bus provider.Bus + w.run, w.p, bus, _ = moved(w.file, w.h) + _ = bus + if err := w.run.watch(ctx, w.bus); err != nil { + t.Fatal(err) + } + return w +} + +// pass is one helper pass and one retirement pass, five seconds after the last. +func (w *movedWorld) pass() []provider.Outcome { + w.now = w.now.Add(5 * time.Second) + // the plans as the bus holds them now, as the watch would hand them + w.run.assignments = map[string][]byte{} + for k, v := range w.bus.buckets[provider.AssignmentsBucket] { + w.run.assignments[k] = v + } + // the helper reads its buckets from the in-memory bus + w.p = provider.New(provider.Options{Node: "novox", Module: "postgres", Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, + Serves: map[string]any{"port": servedPort}}}, + Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}}) + out, err := w.p.Pass(ctx) + if err != nil { + w.t.Fatal(err) + } + w.run.retire(ctx) + return out +} + +func bindings(n int) (map[[2]string]string, []string) { + bound := map[[2]string]string{} + var logins []string + for i := 1; i <= n; i++ { + module := fmt.Sprintf("app%d", i) + bound[[2]string{"ace", module}] = "pw-" + module + logins = append(logins, provider.ConsumerIdentity("ace", module)) + } + return bound, logins +} + +// The consumers' file makes the same calls on the adapter as the equivalent grant files do (the harness). +func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { + letta := provider.ConsumerIdentity("ace", "letta") + plain := provider.ConsumerIdentity("ace", "plain") + + // Today: the grant files, through the harness. + g := newWorld(t) + g.give( + map[string]any{"as": letta, "node": "ace", "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, + map[string]any{"as": plain, "node": "ace", "values": map[string]any{"name": "plain"}}, + ) + g.h.Reconcile(ctx) + + // The consumers' file, through the provider helper. + w := newMovedWorld(t, map[[2]string]string{{"ace", "letta"}: "pw-" + letta, {"ace", "plain"}: "pw-" + plain}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta", "extensions": []any{"vector"}}) + w.bus.bind("ace", "plain", map[string]any{"name": "plain"}) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + byAs := map[string]Provision{} + for _, c := range w.a.created { + byAs[c.As] = c + } + if len(w.a.created) != 2 { + t.Fatalf("created %d, want 2", len(w.a.created)) + } + for _, want := range g.a.created { + c := byAs[want.As] + if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || + !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { + t.Fatalf("made %+v, the grant files make %+v", c, want) + } + } + // Unchanged, and held: nothing is made again, within a minute or after it. + w.a.held = true + w.pass() + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 2 { + t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) + } + // No longer held: made again. + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 4 { + t.Fatalf("not made again: %d", len(w.a.created)) + } +} + +// One read without a consumer retires nothing: it takes the harness's stable passes and time, and then the entry +// goes and the login is retired, never dropped. +func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { + bound, logins := bindings(2) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.bus.bind("ace", "app2", nil) + w.pass() + + // One read without app2. + saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"] + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v on one read", w.a.removed) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept { + t.Fatal("the entry went on one read") + } + // It is back: nothing was lost, and the entry never left the bucket. + w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved) + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + if len(w.a.removed) != 0 || len(w.bus.buckets[provider.BucketOf("postgres")]) != 2 { + t.Fatalf("retired %v, entries %d", w.a.removed, len(w.bus.buckets[provider.BucketOf("postgres")])) + } + + // Gone for good: not retired before StablePasses and StableFor, then retired and its entry removed. + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + for i := 0; i < 6; i++ { + w.pass() + } + if len(w.a.removed) != 0 { + t.Fatalf("retired %v before the time was out", w.a.removed) + } + w.now = w.now.Add(11 * time.Minute) + w.pass() + w.pass() + if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 { + t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; kept { + t.Fatal("the entry stayed after the login was retired") + } +} + +// More than three at once waits for a person, as the harness's bound says. +func TestMoreThanTheBoundWaitsForAPerson(t *testing.T) { + bound, _ := bindings(5) + w := newMovedWorld(t, bound) + for i := 1; i <= 5; i++ { + w.bus.bind("ace", fmt.Sprintf("app%d", i), nil) + } + w.pass() + for i := 1; i <= 4; i++ { + w.bus.Delete(ctx, provider.AssignmentsBucket, fmt.Sprintf("ace.app%d", i)) + } + for i := 0; i < 6; i++ { + w.pass() + } + w.now = w.now.Add(11 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 || w.h.r.waiting == nil { + t.Fatalf("retired %v, waiting %v", w.a.removed, w.h.r.waiting) + } +} + +// A plan list that was never read is not nobody asking. +func TestNoPlansReadRetiresNothing(t *testing.T) { + bound, _ := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.pass() + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1") + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(20 * time.Minute) + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v with no plans read", w.a.removed) + } +} + +// A consumer whose credential is not in the file yet is waited for: nothing is made for it. +func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta"}) + outcomes := w.pass() + if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 { + t.Fatalf("%+v, created %v", outcomes, w.a.created) + } +} + +// Where the file is not there, postgres takes the grant directory, as before; where it is, the helper. +func TestTheGrantPathIsTakenWhereTheConsumersFileIsAbsent(t *testing.T) { + dir := t.TempDir() + at := filepath.Join(dir, "consumers.json") + env := func(k string) string { + if k == "MESH_CONSUMERS_FILE" { + return at + } + return "" + } + if got := handedConsumersFile(env, os.Stat); got != "" { + t.Fatalf("took %q for a file that is not there", got) + } + if got := handedConsumersFile(func(string) string { return "" }, os.Stat); got != "" { + t.Fatalf("took %q with no environment", got) + } + if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + if got := handedConsumersFile(env, os.Stat); got != at { + t.Fatalf("took %q, want %q", got, at) + } +} + +// The grant path restarts itself when the consumers file is first filled. +func TestTheGrantPathLeavesWhenTheConsumersFileIsFilled(t *testing.T) { + at := filepath.Join(t.TempDir(), "consumers.json") + done := make(chan struct{}) + go exitWhenFilled(at, os.Stat, time.Millisecond, func() { close(done) }) + select { + case <-done: + t.Fatal("left before the file was there") + case <-time.After(20 * time.Millisecond): + } + if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("did not leave when the file was filled") + } +} + +// A consumer whose module has a slug keeps the login its plan names, the slug first (issue 788): the adapter makes it +// under that login, and the retirement counts it as asked for, so a pass retires nothing. +func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { + agent := provider.ConsumerIdentity("ace", "agent") + w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"}) + plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "claude-code", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "postgres", "as": agent}}}}) + if err := w.bus.Put(ctx, provider.AssignmentsBucket, "ace.claude-code", plan); err != nil { + t.Fatal(err) + } + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || w.a.created[0].As != agent { + t.Fatalf("made %+v, want one under %s", w.a.created, agent) + } + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v: the plan's login was counted unasked", w.a.removed) + } +} diff --git a/modules/postgres/files/consumers.json b/modules/postgres/files/consumers.json new file mode 100644 index 00000000..30d160e4 --- /dev/null +++ b/modules/postgres/files/consumers.json @@ -0,0 +1 @@ +${consumers:postgres-database} diff --git a/modules/postgres/go.mod b/modules/postgres/go.mod index d4bf4ff3..820d0e67 100644 --- a/modules/postgres/go.mod +++ b/modules/postgres/go.mod @@ -3,7 +3,7 @@ module postgres go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.7 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 github.com/jackc/pgx/v5 v5.11.0 ) diff --git a/modules/postgres/go.sum b/modules/postgres/go.sum index 79d630df..929f7d28 100644 --- a/modules/postgres/go.sum +++ b/modules/postgres/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/modules/postgres/module.json b/modules/postgres/module.json index b55ed746..934c2ef4 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -1,6 +1,7 @@ { "module": "postgres", "version": "1", + "moved": true, "upgrade": { "policy": "record", "why": "a provider whose restart drops every consumer on its machine, and whose new major version changes its data's format: a person takes each build, after a backup (hq ADR 0236)" @@ -102,6 +103,13 @@ "type": "directory", "mode": "0700" }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, { "id": "store-data", "type": "directory", @@ -159,9 +167,21 @@ "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json", - "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/modules/power/README.md b/modules/power/README.md index d1076525..e353aaa0 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -35,8 +35,9 @@ resume units, is not a contribution. Its module keeps its own drop-ins on the sl ## The bar's battery and power draw (novox/hq ADR 0255, ADR 0260) The module holds the machine's power everywhere, so it gives the bar what is generic about power, on -the machines that have it. Both are contributions to `node-bar` in the bar's own neutral terms, so any -bar renders them, and each names the capability the node-engine reports for it: +the machines that have it. Both are items of the collection `contribution:node-bar:block`, each the block text in +i3status-rust's grammar (`templates/bar-battery`, `templates/bar-power-draw`), placed on the bottom bar's status +place, and each names the capability the node-engine reports for it: | block | where | what it shows | |---|---|---| diff --git a/modules/power/cmd/power/templates_test.go b/modules/power/cmd/power/templates_test.go new file mode 100644 index 00000000..db4b3f77 --- /dev/null +++ b/modules/power/cmd/power/templates_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731), +// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given. +func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Templates string `json:"templates"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Templates != "templates" { + t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates) + } + files := 0 + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"]) + } + if r["type"] != "file" { + continue + } + files++ + name, _ := r["template"].(string) + if !strings.HasPrefix(name, "templates/") { + t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name) + continue + } + if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() { + t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err) + } + } + if files == 0 { + t.Fatal("no file resource") + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "templates" { + packs++ + if a["name"] != "templates" { + t.Errorf("the templates archive is named %v, not templates", a["name"]) + } + } + } + if packs != 1 { + t.Fatalf("the build packs templates/ into %d archives, not one", packs) + } + // The templates archive is read by the controller, never unpacked onto the machine. + for _, r := range m.Resources { + if r["artifact"] == "templates" { + t.Errorf("resource %v places the templates archive on the machine", r["id"]) + } + } + // And files/ holds what the machine runs, no template. + err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") { + t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// The bar's blocks are two items of the collection the bar places (design 57 L3, issue 781): the text i3status-rust's +// bars hold for the battery and for the power draw, byte for byte as the bar's own template rendered them before, the +// draw's key being the machine's own name. +func TestTheBarBlocksAreTwoItemsOfTheBarsCollection(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Contributions []any `json:"contributions"` + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + IfCapability string `json:"if-capability"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.Contributions) != 0 || len(m.Items) != 2 { + t.Fatalf("%d contributions and %d items", len(m.Contributions), len(m.Items)) + } + want := []struct{ name, capability, order, template, text string }{ + {"battery", "battery", "40", "templates/bar-battery", "[[block]]\nblock = \"battery\"\n" + + "format = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\n" + + "not_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\n" + + "empty_format = \"$icon $percentage\"\nmissing_format = \"\"\n\n"}, + {"power-draw", "power-meter", "45", "templates/bar-power-draw", "[[block]]\nblock = \"custom\"\n" + + "command = \"~/.local/bin/i3status-value power.draw ${machine:name}\"\ninterval = 5\nhide_when_empty = true\n\n"}, + } + for i, w := range want { + it := m.Items[i] + if it.Collection != "contribution:node-bar:block" || it.Name != w.name || it.IfCapability != w.capability || + it.Template != w.template || it.Attributes["bar"] != "bottom" || it.Attributes["place"] != "status" || + it.Attributes["order"] != w.order || len(it.Attributes) != 3 { + t.Errorf("item %d is %+v", i, it) + } + text, err := os.ReadFile(filepath.Join("../..", w.template)) + if err != nil || string(text) != w.text { + t.Errorf("%s holds %q (%v), want %q", w.template, text, err, w.text) + } + } +} diff --git a/modules/power/module.json b/modules/power/module.json index 6411982c..cea72ad7 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -1,6 +1,8 @@ { "module": "power", "version": "1", + "moved": true, + "templates": "templates", "capabilities": [ "package-manager", "service-manager" @@ -34,6 +36,88 @@ "power_run", "power_check" ], + "config": { + "handle-power-key": { + "type": "string", + "description": "What logind does when the power key is pressed: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch": { + "type": "string", + "description": "What logind does when the lid is closed on battery: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-external-power": { + "type": "string", + "description": "What logind does when the lid is closed on external power: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-docked": { + "type": "string", + "description": "What logind does when the lid is closed while docked or with another screen attached: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + } + }, "resources": [ { "id": "polkit", @@ -63,42 +147,42 @@ "type": "file", "path": "/etc/mesh-power/moments/after-boot", "mode": "0644", - "content": "# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last}" + "template": "templates/mesh-power/moments/after-boot" }, { "id": "moment-before-sleep", "type": "file", "path": "/etc/mesh-power/moments/before-sleep", "mode": "0644", - "content": "# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last}" + "template": "templates/mesh-power/moments/before-sleep" }, { "id": "moment-after-wake", "type": "file", "path": "/etc/mesh-power/moments/after-wake", "mode": "0644", - "content": "# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last}" + "template": "templates/mesh-power/moments/after-wake" }, { "id": "moment-before-shutdown", "type": "file", "path": "/etc/mesh-power/moments/before-shutdown", "mode": "0644", - "content": "# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last}" + "template": "templates/mesh-power/moments/before-shutdown" }, { "id": "moment-on-mains", "type": "file", "path": "/etc/mesh-power/moments/on-mains", "mode": "0644", - "content": "# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last}" + "template": "templates/mesh-power/moments/on-mains" }, { "id": "moment-on-battery", "type": "file", "path": "/etc/mesh-power/moments/on-battery", "mode": "0644", - "content": "# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last}" + "template": "templates/mesh-power/moments/on-battery" }, { "id": "logind-drop-ins", @@ -111,7 +195,7 @@ "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# The power key and the lid, from this module's settings: the mesh's, then this machine's.\n[Login]\nHandlePowerKey=${setting:handle-power-key}\nHandleLidSwitch=${setting:handle-lid-switch}\nHandleLidSwitchExternalPower=${setting:handle-lid-switch-external-power}\nHandleLidSwitchDocked=${setting:handle-lid-switch-docked}\n" + "template": "templates/systemd/logind.conf.d/power.conf" }, { "id": "logind", @@ -126,35 +210,35 @@ "type": "file", "path": "/etc/systemd/system/mesh-power-after-boot.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nDescription=Every module's code after boot (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "templates/systemd/system/mesh-power-after-boot.service" }, { "id": "before-shutdown-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-before-shutdown.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at\n# shutdown it stops, and runs every module's code, while the network is still up.\n[Unit]\nDescription=Every module's code before shutdown (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/bin/true\nExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown\nTimeoutStopSec=120\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "templates/systemd/system/mesh-power-before-shutdown.service" }, { "id": "before-sleep-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-before-sleep.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting\n# for a sleep is not a service whose state the mesh can declare.\n[Unit]\nDescription=Every module's code before sleep (mesh power)\nBefore=sleep.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep\n" + "template": "templates/systemd/system/mesh-power-before-sleep.service" }, { "id": "after-wake-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-after-wake.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after\n# them, so it runs once the machine is awake again. Never enabled.\n[Unit]\nDescription=Every module's code after waking (mesh power)\nAfter=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake\n" + "template": "templates/systemd/system/mesh-power-after-wake.service" }, { "id": "supply-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-supply.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per\n# change of source.\n[Unit]\nDescription=Every module's code for the power source (mesh power)\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment supply\n" + "template": "templates/systemd/system/mesh-power-supply.service" }, { "id": "sleep-drop-ins", @@ -167,7 +251,7 @@ "type": "file", "path": "/etc/systemd/system/sleep.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-before-sleep.service\n" + "template": "templates/systemd/system/sleep.target.d/mesh-power.conf" }, { "id": "suspend-drop-ins", @@ -180,7 +264,7 @@ "type": "file", "path": "/etc/systemd/system/suspend.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/suspend.target.d/mesh-power.conf" }, { "id": "hibernate-drop-ins", @@ -193,7 +277,7 @@ "type": "file", "path": "/etc/systemd/system/hibernate.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/hibernate.target.d/mesh-power.conf" }, { "id": "hybrid-sleep-drop-ins", @@ -206,7 +290,7 @@ "type": "file", "path": "/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf" }, { "id": "suspend-then-hibernate-drop-ins", @@ -219,14 +303,14 @@ "type": "file", "path": "/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf" }, { "id": "udev-rule", "type": "file", "path": "/etc/udev/rules.d/90-mesh-power.rules", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n# Only the charger's change can switch the machine between mains and battery. A battery reports its\n# level many times a minute, and USB-C supplies come and go; matching them started the supply unit\n# every second or two on the laptop.\nSUBSYSTEM==\"power_supply\", ACTION==\"change\", ATTR{type}==\"Mains\", RUN+=\"/usr/bin/systemctl --no-block start mesh-power-supply.service\"\n" + "template": "templates/udev/rules.d/90-mesh-power.rules" }, { "id": "after-boot", @@ -279,34 +363,36 @@ "name": "scripts", "kind": "archive", "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] }, - "contributions": [ + "items": [ { - "seat": "node-bar", - "kind": "block", + "collection": "contribution:node-bar:block", + "name": "battery", "if-capability": "battery", - "data": { + "attributes": { "bar": "bottom", "place": "status", - "order": 40, - "shows": "battery" - } + "order": "40" + }, + "template": "templates/bar-battery" }, { - "seat": "node-bar", - "kind": "block", + "collection": "contribution:node-bar:block", + "name": "power-draw", "if-capability": "power-meter", - "data": { + "attributes": { "bar": "bottom", "place": "status", - "order": 45, - "shows": "state", - "options": { - "state": "power.draw" - } - } + "order": "45" + }, + "template": "templates/bar-power-draw" } ] } diff --git a/modules/power/templates/bar-battery b/modules/power/templates/bar-battery new file mode 100644 index 00000000..83b15e0a --- /dev/null +++ b/modules/power/templates/bar-battery @@ -0,0 +1,8 @@ +[[block]] +block = "battery" +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" diff --git a/modules/power/templates/bar-power-draw b/modules/power/templates/bar-power-draw new file mode 100644 index 00000000..e78da74d --- /dev/null +++ b/modules/power/templates/bar-power-draw @@ -0,0 +1,5 @@ +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw ${machine:name}" +interval = 5 +hide_when_empty = true diff --git a/modules/power/templates/mesh-power/moments/after-boot b/modules/power/templates/mesh-power/moments/after-boot new file mode 100644 index 00000000..c5a90f6b --- /dev/null +++ b/modules/power/templates/mesh-power/moments/after-boot @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last} \ No newline at end of file diff --git a/modules/power/templates/mesh-power/moments/after-wake b/modules/power/templates/mesh-power/moments/after-wake new file mode 100644 index 00000000..2655c226 --- /dev/null +++ b/modules/power/templates/mesh-power/moments/after-wake @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last} \ No newline at end of file diff --git a/modules/power/templates/mesh-power/moments/before-shutdown b/modules/power/templates/mesh-power/moments/before-shutdown new file mode 100644 index 00000000..65cc76d3 --- /dev/null +++ b/modules/power/templates/mesh-power/moments/before-shutdown @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last} \ No newline at end of file diff --git a/modules/power/templates/mesh-power/moments/before-sleep b/modules/power/templates/mesh-power/moments/before-sleep new file mode 100644 index 00000000..e5fc2a5f --- /dev/null +++ b/modules/power/templates/mesh-power/moments/before-sleep @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last} \ No newline at end of file diff --git a/modules/power/templates/mesh-power/moments/on-battery b/modules/power/templates/mesh-power/moments/on-battery new file mode 100644 index 00000000..58e41a97 --- /dev/null +++ b/modules/power/templates/mesh-power/moments/on-battery @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last} \ No newline at end of file diff --git a/modules/power/templates/mesh-power/moments/on-mains b/modules/power/templates/mesh-power/moments/on-mains new file mode 100644 index 00000000..b4a65866 --- /dev/null +++ b/modules/power/templates/mesh-power/moments/on-mains @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last} \ No newline at end of file diff --git a/modules/power/templates/systemd/logind.conf.d/power.conf b/modules/power/templates/systemd/logind.conf.d/power.conf new file mode 100644 index 00000000..9cf93bdc --- /dev/null +++ b/modules/power/templates/systemd/logind.conf.d/power.conf @@ -0,0 +1,8 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# The power key and the lid, from this module's settings: the mesh's, then this machine's. +[Login] +HandlePowerKey=${config:handle-power-key} +HandleLidSwitch=${config:handle-lid-switch} +HandleLidSwitchExternalPower=${config:handle-lid-switch-external-power} +HandleLidSwitchDocked=${config:handle-lid-switch-docked} diff --git a/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf b/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf b/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/power/templates/systemd/system/mesh-power-after-boot.service b/modules/power/templates/systemd/system/mesh-power-after-boot.service new file mode 100644 index 00000000..b7045774 --- /dev/null +++ b/modules/power/templates/systemd/system/mesh-power-after-boot.service @@ -0,0 +1,13 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Description=Every module's code after boot (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot + +[Install] +WantedBy=multi-user.target diff --git a/modules/power/templates/systemd/system/mesh-power-after-wake.service b/modules/power/templates/systemd/system/mesh-power-after-wake.service new file mode 100644 index 00000000..773b2284 --- /dev/null +++ b/modules/power/templates/systemd/system/mesh-power-after-wake.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after +# them, so it runs once the machine is awake again. Never enabled. +[Unit] +Description=Every module's code after waking (mesh power) +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake diff --git a/modules/power/templates/systemd/system/mesh-power-before-shutdown.service b/modules/power/templates/systemd/system/mesh-power-before-shutdown.service new file mode 100644 index 00000000..52db0a4c --- /dev/null +++ b/modules/power/templates/systemd/system/mesh-power-before-shutdown.service @@ -0,0 +1,18 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at +# shutdown it stops, and runs every module's code, while the network is still up. +[Unit] +Description=Every module's code before shutdown (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/true +ExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/modules/power/templates/systemd/system/mesh-power-before-sleep.service b/modules/power/templates/systemd/system/mesh-power-before-sleep.service new file mode 100644 index 00000000..727587e3 --- /dev/null +++ b/modules/power/templates/systemd/system/mesh-power-before-sleep.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting +# for a sleep is not a service whose state the mesh can declare. +[Unit] +Description=Every module's code before sleep (mesh power) +Before=sleep.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep diff --git a/modules/power/templates/systemd/system/mesh-power-supply.service b/modules/power/templates/systemd/system/mesh-power-supply.service new file mode 100644 index 00000000..5096a00e --- /dev/null +++ b/modules/power/templates/systemd/system/mesh-power-supply.service @@ -0,0 +1,10 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per +# change of source. +[Unit] +Description=Every module's code for the power source (mesh power) + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment supply diff --git a/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf b/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf new file mode 100644 index 00000000..096f7935 --- /dev/null +++ b/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-before-sleep.service diff --git a/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf b/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf b/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/power/templates/udev/rules.d/90-mesh-power.rules b/modules/power/templates/udev/rules.d/90-mesh-power.rules new file mode 100644 index 00000000..d0a75bbf --- /dev/null +++ b/modules/power/templates/udev/rules.d/90-mesh-power.rules @@ -0,0 +1,5 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# Only the charger's change can switch the machine between mains and battery. A battery reports its +# level many times a minute, and USB-C supplies come and go; matching them started the supply unit +# every second or two on the laptop. +SUBSYSTEM=="power_supply", ACTION=="change", ATTR{type}=="Mains", RUN+="/usr/bin/systemctl --no-block start mesh-power-supply.service" diff --git a/modules/powerlevel10k/files/zsh-normal b/modules/powerlevel10k/files/zsh-normal new file mode 100644 index 00000000..ba4fc1d6 --- /dev/null +++ b/modules/powerlevel10k/files/zsh-normal @@ -0,0 +1,3 @@ +# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration. +[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme +[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh diff --git a/modules/powerlevel10k/module.json b/modules/powerlevel10k/module.json index 574a01ba..39d2810d 100644 --- a/modules/powerlevel10k/module.json +++ b/modules/powerlevel10k/module.json @@ -1,13 +1,7 @@ { "module": "powerlevel10k", "version": "1", - "shell": [ - { - "for": "zsh", - "slot": "normal", - "code": "# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.\n[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh\n" - } - ], + "moved": true, "resources": [ { "id": "theme", @@ -24,6 +18,12 @@ "artifact": "configuration" } ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], "build": { "artifacts": [ { @@ -35,6 +35,11 @@ "name": "configuration", "kind": "archive", "from": "config" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/powerlevel10k/test/module.test.ts b/modules/powerlevel10k/test/module.test.ts index 0fab4b1a..e9bc8522 100644 --- a/modules/powerlevel10k/test/module.test.ts +++ b/modules/powerlevel10k/test/module.test.ts @@ -26,14 +26,20 @@ test("both are unpacked under the account's home, owned by the account", () => { assert.deepEqual(byId.configuration, { id: "configuration", type: "archive", path: "${machine:account-home}/.config/powerlevel10k", owner: "${machine:account}", artifact: "configuration" }); }); +// The zsh code is an item of the collection shell:zsh:normal, its text a template in files/ (ADR 0348, 0349). +const shellCode = (): string => { + const items = m.items.filter((i: { collection: string }) => i.collection.startsWith("shell:")); + assert.equal(items.length, 1); + assert.equal(items[0].collection, "shell:zsh:normal", "for zsh, in the normal slot"); + return readFileSync(at(items[0].template), "utf8"); +}; + test("the zsh code in the normal slot sources the theme, then the configuration, and turns on no instant prompt", () => { - assert.equal(m.shell.length, 1); - const [c] = m.shell; - assert.equal(c.for, "zsh"); - assert.equal(c.slot, "normal"); - const sourced = [...(c.code as string).matchAll(/\|\| source (\S+)/g)].map((x) => x[1]); + assert.equal(m.shell, undefined); + const code = shellCode(); + const sourced = [...code.matchAll(/\|\| source (\S+)/g)].map((x) => x[1]); assert.deepEqual(sourced, ["~/.local/share/powerlevel10k/powerlevel10k.zsh-theme", "~/.config/powerlevel10k/p10k.zsh"]); - assert.doesNotMatch(c.code, /instant/); + assert.doesNotMatch(code, /instant/); }); test("the README names the upstream, the version and the licence", () => { @@ -41,5 +47,5 @@ test("the README names the upstream, the version and the licence", () => { assert.match(readme, /github\.com\/romkatv\/powerlevel10k/); assert.match(readme, /v1\.20\.0/); assert.match(readme, /MIT/); - assert.match(m.shell[0].code, /v1\.20\.0/, "the version in the shell code's comment matches"); + assert.match(shellCode(), /v1\.20\.0/, "the version in the shell code's comment matches"); }); diff --git a/modules/records/cmd/records/templates_test.go b/modules/records/cmd/records/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/records/cmd/records/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/records/files/config.json b/modules/records/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/records/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/records/files/origin b/modules/records/files/origin new file mode 100644 index 00000000..8c829dc9 --- /dev/null +++ b/modules/records/files/origin @@ -0,0 +1 @@ +${bound:git:scheme}://${bound:git:at}:${bound:git:port} diff --git a/modules/records/module.json b/modules/records/module.json index 1ba903da..9fcf22f5 100644 --- a/modules/records/module.json +++ b/modules/records/module.json @@ -29,6 +29,15 @@ } ] }, + "config": { + "repository": { + "type": "string", + "description": "The repository the records module keeps a checkout of and serves the record from, as owner/name on the mesh's forge; the operator's, with no default (novox/hq ADR 0112).", + "pattern": "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "mesh-state", @@ -47,7 +56,8 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["repository"], "merge": "json" }, { @@ -55,7 +65,7 @@ "type": "file", "path": "${dir:mesh-state}/origin", "mode": "0600", - "content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n" + "template": "files/origin" }, { "id": "git", @@ -80,6 +90,11 @@ "MESH_RECORDS_ORIGIN_FILE": "${dir:mesh-state}/origin", "MESH_RECORDS_DIR": "${dir:checkout}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/redis/files/redis.conf b/modules/redis/files/redis.conf new file mode 100644 index 00000000..ac27331f --- /dev/null +++ b/modules/redis/files/redis.conf @@ -0,0 +1,3 @@ +requirepass ${secret:secret} +appendonly yes +dir /data diff --git a/modules/redis/module.json b/modules/redis/module.json index bdb344a4..fc67a289 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -86,7 +86,7 @@ "type": "file", "path": "${dir:state}/redis.conf", "mode": "0600", - "content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n", + "template": "files/redis.conf", "owner": "999:1000" }, { @@ -140,6 +140,11 @@ "MESH_PROVISION_REDIS": "127.0.0.1:${port:6379}", "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/default.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/restic/cmd/restic-backups/retention_test.go b/modules/restic/cmd/restic-backups/retention_test.go index b06ad26e..2e0ce490 100644 --- a/modules/restic/cmd/restic-backups/retention_test.go +++ b/modules/restic/cmd/restic-backups/retention_test.go @@ -159,25 +159,28 @@ func TestARetentionThatIsNotACountThinsNothingAndSaysWhich(t *testing.T) { } } -// The manifest declares the three preferences with today's values as their defaults, and its file, +// The manifest declares the retention's config values with today's values as their defaults, and its file, // filled with those defaults, reads as today's retention: nothing changes on a machine that sets none. func TestTheManifestsDefaultsAreTodaysRetention(t *testing.T) { raw, err := os.ReadFile("../../module.json") must(t, err) var m struct { - Settings map[string]struct { - Kind string `json:"kind"` - Default any `json:"default"` - Why string `json:"why"` - } `json:"settings"` + Config map[string]struct { + Type string `json:"type"` + Description string `json:"description"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Default any `json:"default"` + } `json:"config"` Resources []map[string]any `json:"resources"` } must(t, json.Unmarshal(raw, &m)) want := map[string]float64{"keep-daily": 14, "keep-weekly": 8, "keep-monthly": 6, "keep-hand-taken-days": 7} for k, v := range want { - s, ok := m.Settings[k] - if !ok || s.Kind != "preference" || s.Default != v || s.Why == "" { - t.Errorf("setting %s: %+v, want a preference defaulting to %v with why", k, s, v) + s, ok := m.Config[k] + if !ok || s.Type != "number" || s.Default != v || s.Description == "" || s.Minimum == nil || *s.Minimum != 1 || + s.Maximum == nil || *s.Maximum != 9999 { + t.Errorf("config value %s: %+v, want a number from 1 to 9999 defaulting to %v with a description", k, s, v) } } content := "" @@ -186,15 +189,15 @@ func TestTheManifestsDefaultsAreTodaysRetention(t *testing.T) { if r["path"] != "${dir:state}/"+RetentionFile { t.Errorf("the retention file is placed at %v, where this program does not read it", r["path"]) } - content, _ = r["content"].(string) + content = templateText(t, r) } } if content == "" { t.Fatal("no retention resource in the manifest") } - filled := regexp.MustCompile(`\$\{setting:([a-z-]+)\}`).ReplaceAllStringFunc(content, func(p string) string { - k := p[len("${setting:") : len(p)-1] - return fmt.Sprint(m.Settings[k].Default) + filled := regexp.MustCompile(`\$\{config:([a-z-]+)\}`).ReplaceAllStringFunc(content, func(p string) string { + k := p[len("${config:") : len(p)-1] + return fmt.Sprint(m.Config[k].Default) }) got, err := ParseRetention(filled) must(t, err) diff --git a/modules/restic/cmd/restic-backups/templates_test.go b/modules/restic/cmd/restic-backups/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/restic/cmd/restic-backups/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/restic/files/backups.conf b/modules/restic/files/backups.conf new file mode 100644 index 00000000..2f23f90f --- /dev/null +++ b/modules/restic/files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/modules/restic/files/data.conf b/modules/restic/files/data.conf new file mode 100644 index 00000000..f19e8499 --- /dev/null +++ b/modules/restic/files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/modules/restic/files/retention.conf b/modules/restic/files/retention.conf new file mode 100644 index 00000000..cd7954c6 --- /dev/null +++ b/modules/restic/files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/modules/restic/module.json b/modules/restic/module.json index 604ce0c7..e7089278 100644 --- a/modules/restic/module.json +++ b/modules/restic/module.json @@ -1,6 +1,7 @@ { "module": "restic", "version": "1", + "moved": true, "claims": [ { "name": "node-backup", @@ -37,28 +38,40 @@ } ] }, - "settings": { - "keep-daily": { - "kind": "preference", - "default": 14, - "why": "two weeks of nightly restore points undo a mistake noticed within a fortnight; a count from 1 to 9999, read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } }, - "keep-weekly": { - "kind": "preference", - "default": 8, - "why": "two months of weekly restore points behind the dailies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" - }, - "keep-monthly": { - "kind": "preference", - "default": 6, - "why": "half a year of monthly restore points behind the weeklies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" - }, - "keep-hand-taken-days": { - "kind": "preference", - "default": 7, - "why": "a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453); a count from 1 to 9999, refused by the night like keep-daily; lowering it lets go of the restore points taken by hand that are older than the new count, past a later night, at the next thinning, and that cannot be undone" - } - }, "resources": [ { "id": "state", @@ -76,14 +89,14 @@ "type": "file", "path": "${dir:state}/backups.conf", "mode": "0600", - "content": "# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.\n${contribution:node-backup:backup}" + "template": "files/backups.conf" }, { "id": "data-declared", "type": "file", "path": "${dir:state}/data.conf", "mode": "0600", - "content": "# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.\n${contribution:node-backup:data}" + "template": "files/data.conf" }, { "id": "retention", @@ -91,7 +104,7 @@ "path": "${dir:state}/retention.conf", "mode": "0644", "trusted": true, - "content": "# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,\n# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.\ndaily=${setting:keep-daily}\nweekly=${setting:keep-weekly}\nmonthly=${setting:keep-monthly}\nhand=${setting:keep-hand-taken-days}\n" + "template": "files/retention.conf" }, { "id": "tool", @@ -170,6 +183,11 @@ "MESH_BACKUP_STATE": "${dir:state}", "MESH_BACKUP_DATA": "${dir:state}/data.conf" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/rofi/cmd/rofi-tools/main.go b/modules/rofi/cmd/rofi-tools/main.go index 3137e9e7..a79d2980 100644 --- a/modules/rofi/cmd/rofi-tools/main.go +++ b/modules/rofi/cmd/rofi-tools/main.go @@ -48,7 +48,7 @@ func tools() []stdio.Tool { Description: fmt.Sprintf("Ask the operator for a value in a prompt that shows nothing of what is typed, "+ "and answer it sealed to the key seal_to names — never in the clear (novox/hq ADR 0259 §10): how the "+ "mesh takes a module's own secret at the desk. The prompt is written here from the names, and says the "+ - "mesh's controller asks: the bus lets nobody else ask it. Answers sealed, or cancelled when the prompt was "+ + "mesh's controller or vault asks: the bus lets nobody else ask it. Answers sealed, or cancelled when the prompt was "+ "dismissed, answered empty or not answered within timeout_seconds (default and at most %d).", menuMost), Input: map[string]any{ "type": "object", @@ -62,7 +62,7 @@ func tools() []stdio.Tool { "required": []string{"module", "secret", "node", "seal_to"}, }, Run: func(args map[string]any) (any, error) { - q, err := secretQuestion(args) + q, err := secretQuestion(stdio.CallerOf(args), args) if err != nil { return nil, err } diff --git a/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go index 441415f9..dff8ee96 100644 --- a/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go +++ b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go @@ -13,6 +13,7 @@ import ( type manifest struct { Module string `json:"module"` + Moved bool `json:"moved"` Version string `json:"version"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` @@ -23,7 +24,8 @@ type manifest struct { Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. - Contributions []contribution `json:"contributions"` + Items []item `json:"items"` + Contributions []any `json:"contributions"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` @@ -85,26 +87,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { @@ -176,31 +218,49 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } -type contribution struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` -} +// displaySession is the collection the module's lines for the window manager are an item of. +const displaySession = "contribution:node-display-session:config" -// i3Lines is what the module contributes to the window manager. +// i3Lines is what the module contributes to the window manager: the text of its item of the collection +// contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string - for _, c := range m.Contributions { - if c.Seat == "node-display-session" && c.Kind == "config" { - out += c.Content + for _, it := range m.Items { + if it.Collection == displaySession { + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + return "" + } + out += string(text) } } return out } -// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no +// old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) + if len(m.Contributions) != 0 { + t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } - if m.i3Lines() != string(want) { - t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + var templates []string + for _, it := range m.Items { + if it.Collection == displaySession { + templates = append(templates, it.Template) + } + } + if len(templates) != 1 || templates[0] != source { + t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) + } + if m.i3Lines() == "" { + t.Fatalf("the template %s holds no line", source) } } + +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} diff --git a/modules/rofi/cmd/rofi-tools/manifest_test.go b/modules/rofi/cmd/rofi-tools/manifest_test.go index bc5eff4f..36f12ec3 100644 --- a/modules/rofi/cmd/rofi-tools/manifest_test.go +++ b/modules/rofi/cmd/rofi-tools/manifest_test.go @@ -37,7 +37,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { "launch": "files/bin/rofi-launch", "powermenu": "files/bin/rofi-powermenu", } { - m.sameAsSource(t, id, source) + m.fromTemplate(t, id, source) } for _, id := range []string{"dmenu", "launch", "powermenu"} { if m.resource(t, id)["mode"] != "0755" { @@ -51,25 +51,25 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { func TestTheThemesAreTheMeshsFacesAndTheConfigurationNamesOne(t *testing.T) { m := readManifest(t) - conf := m.resource(t, "configuration")["content"].(string) + conf := m.text(t, "configuration") if !strings.Contains(conf, `@theme "mesh"`) || strings.Contains(conf, "@import") { t.Fatal("the configuration names the theme mesh and imports nothing") } for _, id := range []string{"theme", "theme-powermenu"} { - c := m.resource(t, id)["content"].(string) + c := m.text(t, id) if !strings.Contains(c, `"JetBrainsMono Nerd Font`) || strings.Contains(c, "@import") || strings.Contains(c, "Hack") || strings.Contains(c, "Iosevka") { t.Errorf("%s: the monospace face, and no imported file", id) } } - if !strings.Contains(m.resource(t, "theme")["content"].(string), `"Inter 11"`) { + if !strings.Contains(m.text(t, "theme"), `"Inter 11"`) { t.Error("the theme's prose is in the interface face") } } func TestThePowerMenuNamesNoWindowManagerAndNoLocker(t *testing.T) { m := readManifest(t) - menu := m.resource(t, "powermenu")["content"].(string) + menu := m.text(t, "powermenu") for _, never := range []string{"i3-msg", "i3lock", "betterlockscreen", "openbox", "bspc", "qdbus", "mpc"} { if strings.Contains(menu, never) { t.Errorf("the power menu names %s", never) @@ -109,3 +109,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} diff --git a/modules/rofi/cmd/rofi-tools/secret.go b/modules/rofi/cmd/rofi-tools/secret.go index d6fe7164..80377868 100644 --- a/modules/rofi/cmd/rofi-tools/secret.go +++ b/modules/rofi/cmd/rofi-tools/secret.go @@ -14,12 +14,13 @@ import ( // node-launcher's verb `secret` (novox/hq ADR 0259 §10): a prompt that does not show what is typed, whose // answer is sealed to the key the asker gives before it leaves this process — never in the clear on the -// bus, in the runtime's log or in any call's record. The controller asks it to take a module's own secret, -// such as a bot token, from the operator at their desk. +// bus, in the runtime's log or in any call's record. The vault asks it, in its own name, to take a module's own +// secret, such as a bot token, from the operator at their desk; the controller asks it for a module still filled +// the old way, until R1 (issue 682). // // **What it does not do**: on an X11 desk any program of the operator's account can read the keys as they // are typed, and a program that asks this verb with a key of its own is answered with what the operator -// typed into a prompt they did not ask for. The bus lets only the controller ask this verb, and the prompt, +// typed into a prompt they did not ask for. The bus lets only the controller and the vault ask this verb, and the prompt, // written here from names, says so and for what, so the operator types only into one they started with give // (ADR 0234's accepted residual risk is what remains: a program drawing a window of its own). @@ -46,11 +47,22 @@ var aName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // break, no markup, no punctuation a sentence is written with — so the prompt's words stay this holder's. var aCaller = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/@ -]{0,79}$`) +// theVault says the caller the runtime proved is the vault's own account (`.mesh-vault`, issue 723): the one +// account beside the controller the bus lets ask this verb, and the only one that asks it in its own name. +func theVault(caller string) bool { return strings.HasSuffix(caller, ".mesh-vault") } + +// escapeMarkup is `what` shown as text, never markup (novox/hq issue 789): the window's message is read with +// pango markup on, so a catalogue's words reach it with the characters pango gives meaning to turned to their +// entities first — never interpreted, whatever a module's manifest holds. +var escapeMarkupReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +func escapeMarkup(s string) string { return escapeMarkupReplacer.Replace(s) } + // secretQuestion reads the call. The prompt is this holder's words, written from the module, the secret and the -// machine (the review of 2026-10-09, M4): it says the mesh's controller asks, which the bus makes true — it -// denies these subjects to every principal but the controller — and a caller's own words are refused, so -// nobody puts a prompt of its choosing before the operator. -func secretQuestion(args map[string]any) (SecretQuestion, error) { +// machine (the review of 2026-10-09, M4): it says the mesh's controller or the mesh's vault asks, which the bus +// makes true — it denies these subjects to every principal but those two, and lets the vault alone call them in +// its name — and a caller's own words are refused, so nobody puts a prompt of its choosing before the operator. +func secretQuestion(caller string, args map[string]any) (SecretQuestion, error) { var q SecretQuestion for _, free := range []string{"prompt", "message"} { if _, there := args[free]; there { @@ -81,14 +93,41 @@ func secretQuestion(args map[string]any) (SecretQuestion, error) { } askedBy = v } - q.Prompt = names["secret"] + " for " + names["module"] - q.Message = fmt.Sprintf("The mesh's controller asks for %s, the own secret of %s on %s.", names["secret"], - names["module"], names["node"]) - if askedBy != "" { - q.Message += fmt.Sprintf(" It was asked for by %s.", askedBy) + // What the secret is for, in the catalogue's own words (novox/hq issue 789): the controller's own line, read + // from the secret's declared `prompt` and never a word the caller chose. One line, so it is shown under the + // module and secret names as its own; its characters are never markup, whatever the catalogue holds. + what := "" + if raw, there := args["what"]; there { + v, err := text(args, "what", true) + if err != nil { + return q, err + } + if strings.ContainsAny(v, "\n\r") { + return q, fmt.Errorf("what %q holds more than one line", raw) + } + what = v } - q.Message += " What you type is not shown, and is sealed before it leaves this machine. Type it only if you " + + q.Prompt = names["secret"] + " for " + names["module"] + asker := "controller" + if theVault(caller) { + asker = "vault" + } + q.Message = fmt.Sprintf("The mesh's %s asks for %s, the own secret of %s on %s.", asker, names["secret"], + names["module"], names["node"]) + if what != "" { + q.Message += "\nFor: " + escapeMarkup(what) + } + tail := "" + if askedBy != "" { + tail += fmt.Sprintf(" It was asked for by %s.", askedBy) + } + tail += " What you type is not shown, and is sealed before it leaves this machine. Type it only if you " + "expect this ask." + if what != "" { + q.Message += "\n" + strings.TrimSpace(tail) + } else { + q.Message += tail + } key, err := text(args, "seal_to", true) if err != nil { return q, err diff --git a/modules/rofi/cmd/rofi-tools/secret_test.go b/modules/rofi/cmd/rofi-tools/secret_test.go index 3aeaf2de..454a97fc 100644 --- a/modules/rofi/cmd/rofi-tools/secret_test.go +++ b/modules/rofi/cmd/rofi-tools/secret_test.go @@ -30,7 +30,7 @@ func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing. bin := fakeBinaries(t, map[string]string{"rofi": `echo "$*" > "$LOG"; echo "` + typedValue + `"`}) t.Setenv("LOG", filepath.Join(bin, "log")) key, pub, priv := aKey(t) - q, err := secretQuestion(map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key}) + q, err := secretQuestion("unproven", map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key}) if err != nil { t.Fatal(err) } @@ -62,7 +62,7 @@ func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing. func TestADismissedEmptyOrLatePromptIsACancelAndAFailureNeverSaysTheValue(t *testing.T) { withSession(t) key, _, _ := aKey(t) - q, _ := secretQuestion(map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key, "timeout_seconds": 1}) + q, _ := secretQuestion("unproven", map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key, "timeout_seconds": 1}) for script, timedOut := range map[string]bool{`exit 1`: false, `echo ""`: false, `sleep 30`: true} { fakeBinaries(t, map[string]string{"rofi": script}) got, err := Secret(q) @@ -92,7 +92,7 @@ func TestASecretIsAskedOnlyWithAKeyToSealItTo(t *testing.T) { named(map[string]any{"seal_to": key, "timeout_seconds": 60}), {"seal_to": key}, } { - if _, err := secretQuestion(args); err == nil { + if _, err := secretQuestion("unproven", args); err == nil { t.Errorf("%v was taken", args) } } @@ -104,7 +104,7 @@ func TestASecretIsAskedOnlyWithAKeyToSealItTo(t *testing.T) { func TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks(t *testing.T) { key := base64.StdEncoding.EncodeToString(make([]byte, 32)) base := map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key} - q, err := secretQuestion(base) + q, err := secretQuestion("unproven", base) if err != nil { t.Fatal(err) } @@ -112,18 +112,26 @@ func TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks(t *testing.T) { strings.Contains(q.Message, "asked for by") { t.Errorf("prompt %q, message %q", q.Prompt, q.Message) } + // Asked by the vault's own account, as the runtime proved it (issue 723): the prompt says the vault asks. + if q, err := secretQuestion("novox.mesh-vault", base); err != nil || + !strings.HasPrefix(q.Message, "The mesh's vault asks for telegram-token") { + t.Errorf("asked by the vault: %q %v", q.Message, err) + } + if q, _ := secretQuestion("novox.mesh-vault-not", base); !strings.HasPrefix(q.Message, "The mesh's controller") { + t.Errorf("a caller that is not the vault's account is said to be the vault: %q", q.Message) + } // And who asked the controller, when it says so (novox/hq ADR 0277): a caller's name, in a name's characters. withAsker := map[string]any{} for k, v := range base { withAsker[k] = v } withAsker["asked_by"] = "g14/claude-code" - q, err = secretQuestion(withAsker) + q, err = secretQuestion("unproven", withAsker) if err != nil || !strings.Contains(q.Message, "It was asked for by g14/claude-code.") { t.Errorf("asked_by: %q %v", q.Message, err) } withAsker["asked_by"] = "jochen at a shell on novox" - if q, err = secretQuestion(withAsker); err != nil || !strings.Contains(q.Message, "It was asked for by jochen at a shell on novox.") { + if q, err = secretQuestion("unproven", withAsker); err != nil || !strings.Contains(q.Message, "It was asked for by jochen at a shell on novox.") { t.Errorf("asked_by at a shell: %q %v", q.Message, err) } for key, value := range map[string]any{ @@ -139,14 +147,72 @@ func TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks(t *testing.T) { args[k] = v } args[key] = value - if _, err := secretQuestion(args); err == nil { + if _, err := secretQuestion("unproven", args); err == nil { t.Errorf("%s=%q was taken", key, value) } } for _, bad := range []string{"", "a\nb", "ac", "a,b", strings.Repeat("a", 81), "a b", "a:b", "a'b"} { withAsker["asked_by"] = bad - if _, err := secretQuestion(withAsker); err == nil { + if _, err := secretQuestion("unproven", withAsker); err == nil { t.Errorf("asked_by=%q was taken", bad) } } } + +// What the secret is for shows as its own line under the module and secret names, in the catalogue's own +// words (novox/hq issue 789): read from the secret's declared `prompt`, never a word the caller chose, and +// never shown without a declared one — an old module, or one that declares none, prompts as it does today. +func TestWhatShowsAsItsOwnLineUnderTheNamesWhenDeclaredAndNothingWhenNot(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + base := map[string]any{"module": "mesh-mail", "secret": "mail-credential-gmail", "node": "anchor", "seal_to": key} + q, err := secretQuestion("unproven", base) + if err != nil { + t.Fatal(err) + } + if strings.Contains(q.Message, "For:") { + t.Errorf("a \"For:\" line with no what declared: %q", q.Message) + } + + withWhat := map[string]any{} + for k, v := range base { + withWhat[k] = v + } + withWhat["what"] = "the password of the mail account gmail" + q, err = secretQuestion("unproven", withWhat) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(q.Message, "\nFor: the password of the mail account gmail") { + t.Errorf("no \"For:\" line of its own: %q", q.Message) + } + if !strings.Contains(q.Message, "What you type is not shown") { + t.Errorf("the rest of the message was lost: %q", q.Message) + } +} + +// What the secret is for is shown as text, never markup (novox/hq issue 789): the prompt's window reads its +// message with markup on, so what the catalogue holds reaches it with the characters markup gives meaning to +// turned to their entities first, and a line holding more than one is refused before anybody sees it. +func TestWhatIsShownAsTextNeverMarkupAndIsOneLine(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + base := map[string]any{"module": "mesh-mail", "secret": "mail-credential-gmail", "node": "anchor", "seal_to": key, + "what": "the password of the mail & account"} + q, err := secretQuestion("unproven", base) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(q.Message, "For: the password of <b>the</b> mail & account") { + t.Errorf("markup was not escaped: %q", q.Message) + } + + for _, bad := range []string{"a\nb", "a\rb"} { + multi := map[string]any{} + for k, v := range base { + multi[k] = v + } + multi["what"] = bad + if _, err := secretQuestion("unproven", multi); err == nil { + t.Errorf("what=%q holding more than one line was taken", bad) + } + } +} diff --git a/modules/rofi/go.mod b/modules/rofi/go.mod index ca15f8ce..9b8d8ad1 100644 --- a/modules/rofi/go.mod +++ b/modules/rofi/go.mod @@ -2,7 +2,7 @@ module rofi go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.7 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 require ( golang.org/x/crypto v0.31.0 diff --git a/modules/rofi/go.sum b/modules/rofi/go.sum index dff94e3d..47386f64 100644 --- a/modules/rofi/go.sum +++ b/modules/rofi/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= diff --git a/modules/rofi/module.json b/modules/rofi/module.json index d603a199..1cc9b2ff 100644 --- a/modules/rofi/module.json +++ b/modules/rofi/module.json @@ -1,6 +1,7 @@ { "module": "rofi", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -48,7 +49,7 @@ "path": "${machine:account-home}/.config/rofi/config.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at\n * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from\n * rofi's defaults, nothing else. Its look is the theme \"mesh\", in themes/ beside this file. */\n\nconfiguration {\n\tmodi: \"drun,run,window,filebrowser\";\n\tshow-icons: true;\n\tcase-sensitive: false;\n\tnormalize-match: true;\n\tmatching: \"normal\";\n\ttokenize: true;\n\tcycle: true;\n\tsteal-focus: false;\n\tclick-to-exit: true;\n\tsort: false;\n\n\t/* Applications: the user's and the system's desktop entries, read fresh each time so a\n\t * program installed a minute ago is there. */\n\tdrun-match-fields: \"name,generic,exec,categories,keywords\";\n\tdrun-display-format: \"{name} [({generic})]\";\n\tdrun-show-actions: false;\n\tdrun-url-launcher: \"xdg-open\";\n\tdrun-use-desktop-cache: false;\n\tdrun-reload-desktop-cache: false;\n\trun,drun {\n\t\tfallback-icon: \"application-x-addon\";\n\t}\n\n\t/* A command run from `run` opens in the node's terminal when it asks for one. */\n\tterminal: \"rofi-sensible-terminal\";\n\trun-shell-command: \"{terminal} -e {cmd}\";\n\tssh-command: \"{terminal} -e {ssh-client} {host} [-p {port}]\";\n\tparse-hosts: true;\n\tparse-known-hosts: true;\n\n\twindow-match-fields: \"title,class,role,name,desktop\";\n\twindow-format: \"{w} - {c} - {t:0}\";\n\n\tdisable-history: false;\n\tmax-history-size: 25;\n\n\tfilebrowser {\n\t\tdirectories-first: true;\n\t\tsorting-method: \"name\";\n\t}\n\n\tdisplay-window: \"Windows\";\n\tdisplay-run: \"Run\";\n\tdisplay-ssh: \"SSH\";\n\tdisplay-drun: \"Apps\";\n\tdisplay-filebrowser: \"Files\";\n}\n\n@theme \"mesh\"\n" + "template": "files/config.rasi" }, { "id": "theme", @@ -56,7 +57,7 @@ "path": "${machine:account-home}/.config/rofi/themes/mesh.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/**\n * The theme \"mesh\" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names\n * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of\n * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is\n * one file, and its face the monospace one every desktop module names.\n **/\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n\n border-colour: var(selected);\n handle-colour: var(selected);\n background-colour: var(background);\n foreground-colour: var(foreground);\n alternate-background: var(background-alt);\n normal-background: var(background);\n normal-foreground: var(foreground);\n urgent-background: var(urgent);\n urgent-foreground: var(background);\n active-background: var(active);\n active-foreground: var(background);\n selected-normal-background: var(selected);\n selected-normal-foreground: var(background);\n selected-urgent-background: var(active);\n selected-urgent-foreground: var(background);\n selected-active-background: var(urgent);\n selected-active-foreground: var(background);\n alternate-normal-background: var(background);\n alternate-normal-foreground: var(foreground);\n alternate-urgent-background: var(urgent);\n alternate-urgent-foreground: var(background);\n alternate-active-background: var(active);\n alternate-active-foreground: var(background);\n}\n\n/*****----- Main Window -----*****/\nwindow {\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 600px;\n x-offset: 0px;\n y-offset: 0px;\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 1px solid;\n border-radius: 0px;\n border-color: @border-colour;\n cursor: \"default\";\n background-color: @background-colour;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n children: [ \"inputbar\", \"message\", \"listview\", \"mode-switcher\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px 0px 1px 0px;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n children: [ \"prompt\", \"entry\" ];\n}\n\nprompt {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n}\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"::\";\n background-color: inherit;\n text-color: inherit;\n}\nentry {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n cursor: text;\n placeholder: \"Search...\";\n placeholder-color: inherit;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 1;\n lines: 8;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n spacing: 0px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: \"default\";\n}\nscrollbar {\n handle-width: 5px;\n handle-color: @handle-colour;\n border-radius: 0px;\n background-color: @alternate-background;\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: pointer;\n}\nelement normal.normal {\n background-color: var(normal-background);\n text-color: var(normal-foreground);\n}\nelement normal.urgent {\n background-color: var(urgent-background);\n text-color: var(urgent-foreground);\n}\nelement normal.active {\n background-color: var(active-background);\n text-color: var(active-foreground);\n}\nelement selected.normal {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\nelement selected.urgent {\n background-color: var(selected-urgent-background);\n text-color: var(selected-urgent-foreground);\n}\nelement selected.active {\n background-color: var(selected-active-background);\n text-color: var(selected-active-foreground);\n}\nelement alternate.normal {\n background-color: var(alternate-normal-background);\n text-color: var(alternate-normal-foreground);\n}\nelement alternate.urgent {\n background-color: var(alternate-urgent-background);\n text-color: var(alternate-urgent-foreground);\n}\nelement alternate.active {\n background-color: var(alternate-active-background);\n text-color: var(alternate-active-foreground);\n}\nelement-icon {\n background-color: transparent;\n text-color: inherit;\n size: 24px;\n cursor: inherit;\n}\nelement-text {\n background-color: transparent;\n text-color: inherit;\n highlight: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n}\n\n/*****----- Mode Switcher -----*****/\nmode-switcher {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\nbutton {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: inherit;\n cursor: pointer;\n}\nbutton selected {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\ntextbox {\n font: \"Inter 11\";\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n highlight: none;\n placeholder-color: @foreground-colour;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @background-colour;\n text-color: @foreground-colour;\n}\n" + "template": "files/themes/mesh.rasi" }, { "id": "theme-powermenu", @@ -64,7 +65,7 @@ "path": "${machine:account-home}/.config/rofi/themes/mesh-powermenu.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/**\n * The theme \"mesh-powermenu\" (module rofi, novox/hq ADR 0208): the power menu's five buttons.\n * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of\n * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined.\n **/\n\n/*****----- Configuration -----*****/\nconfiguration {\n show-icons: false;\n}\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n}\n\n\n/*****----- Main Window -----*****/\nwindow {\n /* properties for window widget */\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 800px;\n x-offset: 0px;\n y-offset: 0px;\n\n /* properties for all widgets */\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n cursor: \"default\";\n background-color: @background;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 30px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n children: [ \"inputbar\", \"listview\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n children: [ \"textbox-prompt-colon\", \"prompt\"];\n}\n\ndummy {\n background-color: transparent;\n}\n\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"\";\n padding: 12px 16px;\n border-radius: 0px;\n background-color: @urgent;\n text-color: @background;\n}\nprompt {\n enabled: true;\n padding: 12px;\n border-radius: 0px;\n background-color: @active;\n text-color: @background;\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n}\ntextbox {\n background-color: inherit;\n text-color: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n placeholder-color: @foreground;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background;\n text-color: @foreground;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 5;\n lines: 1;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n cursor: \"default\";\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 0px;\n margin: 0px;\n padding: 40px 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n cursor: pointer;\n}\nelement-text {\n font: \"JetBrainsMono Nerd Font Bold 32\";\n background-color: transparent;\n text-color: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n}\nelement selected.normal {\n background-color: var(selected);\n text-color: var(background);\n}\n" + "template": "files/themes/mesh-powermenu.rasi" }, { "id": "dmenu", @@ -72,7 +73,7 @@ "path": "${machine:account-home}/.local/bin/dmenu", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher\n# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output,\n# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat.\n#\n# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w)\n# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The\n# look is the launcher's theme.\nset -u\nn=$#\nwhile [ \"$n\" -gt 0 ]; do\n\targ=$1\n\tshift\n\tn=$((n - 1))\n\tcase $arg in\n\t-fn | -nb | -nf | -sb | -sf | -m | -w)\n\t\tif [ \"$n\" -gt 0 ]; then\n\t\t\tshift\n\t\t\tn=$((n - 1))\n\t\tfi\n\t\t;;\n\t-b | -f) ;;\n\t*) set -- \"$@\" \"$arg\" ;;\n\tesac\ndone\nexec rofi -dmenu \"$@\"\n" + "template": "files/bin/dmenu" }, { "id": "launch", @@ -80,7 +81,7 @@ "path": "${machine:account-home}/.local/bin/rofi-launch", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's\n# key bindings run.\n#\n# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press\n# that opens nothing. So a failure within half a second is tried again, up to five times. A person\n# closing the menu takes longer than that, and is never shown it a second time.\nset -u\nmode=${1:-drun}\n\nnow_ms() { date +%s%3N; }\n\nattempt() {\n\ti=0\n\twhile [ \"$i\" -lt 5 ]; do\n\t\tstart=$(now_ms)\n\t\t\"$@\" && return 0\n\t\t[ $(($(now_ms) - start)) -ge 500 ] && return 1\n\t\ti=$((i + 1))\n\t\tsleep 0.1\n\tdone\n\treturn 1\n}\n\ncase $mode in\ndrun | run | window | filebrowser)\n\tattempt rofi -show \"$mode\"\n\t;;\nsudo)\n\t# A command line, run with sudo in the node's terminal.\n\tcommand=$(attempt rofi -dmenu -p sudo &2\n\texit 2\n\t;;\nesac\n" + "template": "files/bin/rofi-launch" }, { "id": "powermenu", @@ -88,7 +89,7 @@ "path": "${machine:account-home}/.local/bin/rofi-powermenu", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n" + "template": "files/bin/rofi-powermenu" } ], "build": { @@ -103,14 +104,19 @@ "loads": [ "rofi-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-rofi.conf" } ] } diff --git a/modules/route-adapter/README.md b/modules/route-adapter/README.md index 2e92b98a..d71d3975 100644 --- a/modules/route-adapter/README.md +++ b/modules/route-adapter/README.md @@ -171,3 +171,11 @@ rather than skipped: the predecessor already served its own registry name this w not a whole positive number of bytes takes the route with it — written without the limit, the predecessor would carry exactly what the module said not to carry, and this module would report success doing it. + +## Handed to the provisioner (issue 792) + +The module is marked `moved`, and a small Go program beside the TypeScript adapter (`cmd/route-provider`) runs the SDK's +provider helper for `route`, with no credential. A consumer of a route whose templates name `${bound:route:name-web}` +(a handed gitea's origins file) is filled by its machine's client from this module's entry for it. The entry's values are +the names the controller composed for that consumer, carried in its plan and copied unchanged: the public domain is +placement, so only the controller can join a label to it. The adapter still writes the predecessor proxy's files. diff --git a/modules/route-adapter/cmd/route-provider/main.go b/modules/route-adapter/cmd/route-provider/main.go new file mode 100644 index 00000000..013c80ae --- /dev/null +++ b/modules/route-adapter/cmd/route-provider/main.go @@ -0,0 +1,37 @@ +// route-provider: route-adapter's Go half, one binary the node's runtime launches and speaks MCP to over stdio +// through the Go SDK. It serves no tools; it runs the SDK's provider helper that answers the consumers of `route` +// (issue 792). The TypeScript adapter beside it keeps writing the proxy's files. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/provider" +) + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[route-adapter] "+format+"\n", args...) +} + +func main() { + node := os.Getenv("MESH_NODE") + if node == "" { + say("MESH_NODE is not set; answering no consumers") + } else { + p := newProvider(node, provider.StateBus(ModuleName), say) + go func() { + if err := p.Run(context.Background()); err != nil { + say("the provider helper stopped: %v", err) + } + }() + } + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } +} diff --git a/modules/route-adapter/cmd/route-provider/provider.go b/modules/route-adapter/cmd/route-provider/provider.go new file mode 100644 index 00000000..5061a668 --- /dev/null +++ b/modules/route-adapter/cmd/route-provider/provider.go @@ -0,0 +1,36 @@ +package main + +// route-adapter handed to the provisioner (novox/hq ADR 0349 clause 12, issue 792): the module runs the SDK's provider +// helper for `route`, so a consumer of a route — a handed gitea reading `${bound:route:name-web}` — is told its public +// name from the entry in this module's bucket, the one its machine's client fills its files from. +// +// **It composes nothing.** A route's public name is the label its consumer chose joined to the node's public domain, +// which only the controller knows (the domain is placement), so the controller composes it and carries it in the +// consumer's plan entry (`names`); the helper copies it into the entry's values unchanged. There is no credential for +// a route and nothing to make: the traefik files stay the TypeScript adapter's, written from the contributions the +// mesh still gives it. + +import ( + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// ProvisionName is what route-adapter provides, as its manifest says. +const ProvisionName = "route" + +// ModuleName is this module. +const ModuleName = "route-adapter" + +// newProvider is route-adapter answering its consumers over bus on node: the route, served as the manifest serves it +// (nothing of its own), with no credential, and nothing to make or retire for a consumer. +func newProvider(node string, bus provider.Bus, logf func(string, ...any)) *provider.Provider { + return provider.New(provider.Options{ + Node: node, + Module: ModuleName, + Offers: map[string]provider.Offer{ProvisionName: { + Credential: provider.NoCredential, + Serves: map[string]any{}, + }}, + Bus: bus, + Logf: logf, + }) +} diff --git a/modules/route-adapter/cmd/route-provider/provider_test.go b/modules/route-adapter/cmd/route-provider/provider_test.go new file mode 100644 index 00000000..10c30933 --- /dev/null +++ b/modules/route-adapter/cmd/route-provider/provider_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// A consumer of a route is answered with the names its plan's entry carries, and nothing of a credential (issue 792): +// the entry's values are the controller's composition, copied unchanged, and no vault is asked. +func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + plan, _ := json.Marshal(map[string]any{"node": "g14", "module": "gitea", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": ModuleName, "at": "10.7.0.1", "from": "novox", "as": "mesh_g14_gitea", + "names": map[string]any{"name": "git.example.org", "internal-name": "git.novox.internal", + "name-web": "git.example.org"}}}}}) + if err := bus.Put(context.Background(), provider.AssignmentsBucket, "g14.gitea", plan); err != nil { + t.Fatal(err) + } + p := newProvider("novox", bus, t.Logf) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("the pass did %+v", out) + } + var e provider.Entry + if err := json.Unmarshal(bus.buckets[provider.BucketOf(ModuleName)]["g14.gitea.route"], &e); err != nil { + t.Fatal(err) + } + if e.Values["name-web"] != "git.example.org" || e.Values["name"] != "git.example.org" || + e.Values["internal-name"] != "git.novox.internal" || e.Credential != "" || e.User != "" || e.Refused != "" { + t.Fatalf("the entry is %+v", e) + } +} diff --git a/modules/route-adapter/files/config.json b/modules/route-adapter/files/config.json new file mode 100644 index 00000000..1d8d8c2a --- /dev/null +++ b/modules/route-adapter/files/config.json @@ -0,0 +1,6 @@ +{ + "dynamic": "/services/traefik/dynamic", + "entrypoint": "websecure", + "certificate-resolver": "le", + "machine": "host.docker.internal" +} diff --git a/modules/route-adapter/go.mod b/modules/route-adapter/go.mod new file mode 100644 index 00000000..792f6ec3 --- /dev/null +++ b/modules/route-adapter/go.mod @@ -0,0 +1,5 @@ +module route-adapter + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 diff --git a/modules/route-adapter/go.sum b/modules/route-adapter/go.sum new file mode 100644 index 00000000..9a531b0e --- /dev/null +++ b/modules/route-adapter/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/route-adapter/module.json b/modules/route-adapter/module.json index 10cb2d98..0f2f37d2 100644 --- a/modules/route-adapter/module.json +++ b/modules/route-adapter/module.json @@ -1,6 +1,7 @@ { "module": "route-adapter", "version": "1", + "moved": true, "slug": "radapt", "capabilities": [ "container-runtime" @@ -25,6 +26,33 @@ "mode": "read-write" } ], + "config": { + "dynamic": { + "type": "string", + "description": "The directory the predecessor proxy's file provider reads, on this machine, as an absolute path.", + "pattern": "/.*", + "trusted": true, + "default": "/services/traefik/dynamic" + }, + "entrypoint": { + "type": "string", + "description": "The predecessor proxy's entry point it serves public HTTPS on, by its own name.", + "trusted": true, + "default": "websecure" + }, + "certificate-resolver": { + "type": "string", + "description": "The predecessor proxy's certificate resolver, by its own name.", + "trusted": true, + "default": "le" + }, + "machine": { + "type": "string", + "description": "How the predecessor's proxy reaches this machine: a host name or an address.", + "trusted": true, + "default": "host.docker.internal" + } + }, "resources": [ { "id": "state", @@ -42,9 +70,10 @@ "id": "config", "type": "file", "path": "${dir:state}/config.json", + "takes-config": ["dynamic", "entrypoint", "certificate-resolver", "machine"], "merge": "json", "mode": "0644", - "content": "{\n \"dynamic\": \"/services/traefik/dynamic\",\n \"entrypoint\": \"websecure\",\n \"certificate-resolver\": \"le\",\n \"machine\": \"host.docker.internal\"\n}\n" + "template": "files/config.json" }, { "id": "adapt", @@ -75,7 +104,29 @@ "entrypoints": [ "index.js" ] + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/modules/route-proxy/Dockerfile b/modules/route-proxy/Dockerfile index b556c2f6..7e7fe648 100644 --- a/modules/route-proxy/Dockerfile +++ b/modules/route-proxy/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-route-proxy ./examples/route-proxy +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-route-proxy ./examples/route-proxy # A small runtime with the public CA roots the ACME client needs to reach a real authority, and run # as root so it can bind :80 and :443 — the two privileged ports a public front door listens on. diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index bc0e0d7a..5b167dfe 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -84,3 +84,10 @@ traffic. | `ACME_CACHE` | `/acme` | where issued certificates persist; required when `TLS_LISTEN` is set, so a restart does not re-order | | `ACME_DIRECTORY` | Let's Encrypt **staging** in the binary; production in the module's `acme.env` | the issuer | | `ACME_CA_BUNDLE` | *(unset)* | a file of roots to trust for the issuer's own API — set only for a private/lab authority whose API certificate the world does not yet trust | + +## Answered by the provider helper (issue 792) + +A small Go program beside the proxy (`cmd/route-provider`) runs the SDK's provider helper for `route`, with no credential, +and the offer says `"answered-by": "helper"`: a handed consumer (gitea's origins file) is filled by its machine's client +from this module's entry, which carries the public names the controller composed for it. The module is **not** marked +moved: its own files, `acme.env` and the broker secret, are composed as before, and the proxy itself is unchanged. diff --git a/modules/route-proxy/cmd/route-provider/main.go b/modules/route-proxy/cmd/route-provider/main.go new file mode 100644 index 00000000..e70a6892 --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/main.go @@ -0,0 +1,37 @@ +// route-provider: route-proxy's Go half, one binary the node's runtime launches and speaks MCP to over stdio +// through the Go SDK. It serves no tools; it runs the SDK's provider helper that answers the consumers of `route` +// (issue 792). The TypeScript adapter beside it keeps writing the proxy's files. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/provider" +) + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[route-proxy] "+format+"\n", args...) +} + +func main() { + node := os.Getenv("MESH_NODE") + if node == "" { + say("MESH_NODE is not set; answering no consumers") + } else { + p := newProvider(node, provider.StateBus(ModuleName), say) + go func() { + if err := p.Run(context.Background()); err != nil { + say("the provider helper stopped: %v", err) + } + }() + } + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } +} diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go new file mode 100644 index 00000000..7eee671b --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/provider.go @@ -0,0 +1,36 @@ +package main + +// route-proxy handed to the provisioner (novox/hq ADR 0349 clause 12, issue 792): the module runs the SDK's provider +// helper for `route`, so a consumer of a route — a handed gitea reading `${bound:route:name-web}` — is told its public +// name from the entry in this module's bucket, the one its machine's client fills its files from. +// +// **It composes nothing.** A route's public name is the label its consumer chose joined to the node's public domain, +// which only the controller knows (the domain is placement), so the controller composes it and carries it in the +// consumer's plan entry (`names`); the helper copies it into the entry's values unchanged. There is no credential for +// a route and nothing to make: the traefik files stay the TypeScript adapter's, written from the contributions the +// mesh still gives it. + +import ( + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// ProvisionName is what route-proxy provides, as its manifest says. +const ProvisionName = "route" + +// ModuleName is this module. +const ModuleName = "route-proxy" + +// newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it +// (nothing of its own), with no credential, and nothing to make or retire for a consumer. +func newProvider(node string, bus provider.Bus, logf func(string, ...any)) *provider.Provider { + return provider.New(provider.Options{ + Node: node, + Module: ModuleName, + Offers: map[string]provider.Offer{ProvisionName: { + Credential: provider.NoCredential, + Serves: map[string]any{}, + }}, + Bus: bus, + Logf: logf, + }) +} diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go new file mode 100644 index 00000000..f5c4b7c2 --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// A consumer of a route is answered with the names its plan's entry carries, and nothing of a credential (issue 792): +// the entry's values are the controller's composition, copied unchanged, and no vault is asked. +func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + plan, _ := json.Marshal(map[string]any{"node": "g14", "module": "gitea", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": ModuleName, "at": "10.7.0.1", "from": "novox", "as": "mesh_g14_gitea", + "names": map[string]any{"name": "git.example.org", "internal-name": "git.novox.internal", + "name-web": "git.example.org"}}}}}) + if err := bus.Put(context.Background(), provider.AssignmentsBucket, "g14.gitea", plan); err != nil { + t.Fatal(err) + } + p := newProvider("novox", bus, t.Logf) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("the pass did %+v", out) + } + var e provider.Entry + if err := json.Unmarshal(bus.buckets[provider.BucketOf(ModuleName)]["g14.gitea.route"], &e); err != nil { + t.Fatal(err) + } + if e.Values["name-web"] != "git.example.org" || e.Values["name"] != "git.example.org" || + e.Values["internal-name"] != "git.novox.internal" || e.Credential != "" || e.User != "" || e.Refused != "" { + t.Fatalf("the entry is %+v", e) + } +} + +// route-proxy's own files and resources stay as they were (issue 792): the module answers its route through the +// helper but is not marked moved, so its acme.env, its broker secret and its containers are composed as today and +// the public ingress does not change. Everything of the manifest but its build, what it provides and the state it +// keeps is pinned by this digest; changing the ingress means changing it here, on purpose. +func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, moved := m["moved"]; moved { + t.Fatal("route-proxy is marked moved: its own files would be handed") + } + provides, _ := m["provides"].([]any) + if len(provides) != 1 || provides[0].(map[string]any)["answered-by"] != "helper" { + t.Fatalf("route is not said to be answered by the helper: %v", provides) + } + for _, k := range []string{"build", "provides", "state", "reads"} { + delete(m, k) + } + pinned, err := json.Marshal(m) // sorted keys, compact + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(pinned) + if got := hex.EncodeToString(sum[:]); got != "57a39de0943d8b33b9db6cb998ce97f8793602cca6cdfa5849c1c392390454a3" { + t.Fatalf("route-proxy's own manifest changed: digest %s", got) + } +} diff --git a/modules/route-proxy/files/acme.env b/modules/route-proxy/files/acme.env new file mode 100644 index 00000000..428aae2b --- /dev/null +++ b/modules/route-proxy/files/acme.env @@ -0,0 +1,3 @@ +ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory +ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443 +ACME_ROOTS_PATH= diff --git a/modules/route-proxy/files/internal-acme.env b/modules/route-proxy/files/internal-acme.env new file mode 100644 index 00000000..72b0f7bb --- /dev/null +++ b/modules/route-proxy/files/internal-acme.env @@ -0,0 +1,3 @@ +INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path} +INTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots} +INTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots} diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod new file mode 100644 index 00000000..ade1dc0a --- /dev/null +++ b/modules/route-proxy/go.mod @@ -0,0 +1,5 @@ +module route-proxy + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum new file mode 100644 index 00000000..9a531b0e --- /dev/null +++ b/modules/route-proxy/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 8b95306b..a2734f17 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -9,7 +9,8 @@ { "name": "route", "scope": "mesh", - "identity": false + "identity": false, + "answered-by": "helper" } ], "serves": { @@ -95,14 +96,14 @@ "type": "file", "path": "${dir:state}/acme.env", "mode": "0600", - "content": "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\nACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\nACME_ROOTS_PATH=\n" + "template": "files/acme.env" }, { "id": "internal-acme-env", "type": "file", "path": "${dir:state}/internal-acme.env", "mode": "0600", - "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" + "template": "files/internal-acme.env" }, { "id": "trust", @@ -207,6 +208,22 @@ "name": "trust", "kind": "upstream", "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ], "on": [ @@ -226,5 +243,11 @@ "failregex": "^.*TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from :\\d+$", "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" } + ], + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" ] } diff --git a/modules/screen-lock/cmd/screen-lock-tools/lock_test.go b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go index b6fc0420..7925c9b6 100644 --- a/modules/screen-lock/cmd/screen-lock-tools/lock_test.go +++ b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go @@ -181,7 +181,7 @@ func TestAnInhibitionTurnsIdleOffAndRestoresWhatWasThereWhenItEndsOrIsStopped(t func TestTheDeclaredTimeoutsAreTheSessionStartsOwn(t *testing.T) { m := readManifest(t) - code := m.Shell[0].Code + code := m.shell(t)[0].Code if !strings.Contains(code, "xset s "+strconv.Itoa(declaredLock)+" "+strconv.Itoa(declaredLock)+"\n") || !strings.Contains(code, "xset dpms "+strconv.Itoa(declaredStandby)+" "+strconv.Itoa(declaredSuspend)+" "+strconv.Itoa(declaredOff)+"\n") { t.Fatalf("the tools' declared values and the session start's disagree:\n%s", code) diff --git a/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go index d4fb76d9..fb992951 100644 --- a/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go +++ b/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go @@ -14,6 +14,8 @@ import ( type manifest struct { Module string `json:"module"` Version string `json:"version"` + Moved bool `json:"moved"` + Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` @@ -44,6 +46,53 @@ type shellCode struct { Code string `json:"code"` } +// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. +type item struct { + Collection string `json:"collection"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Template string `json:"template"` +} + +// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of +// the collections shell::, in the order they are declared. +func (m manifest) shell(t *testing.T) []shellCode { + t.Helper() + var out []shellCode + for _, it := range m.Items { + parts := strings.Split(it.Collection, ":") + if len(parts) != 3 || parts[0] != "shell" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) + } + return out +} + +// environmentOf is the variables the module sets, read from the templates of its items of the environment. +func (m manifest) environmentOf(t *testing.T) *environment { + t.Helper() + var env *environment + for _, it := range m.Items { + if it.Collection != "environment" || it.Attributes["kind"] != "variable" { + continue + } + text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) + if err != nil { + t.Fatal(err) + } + if env == nil { + env = &environment{Variables: map[string]string{}} + } + env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") + } + return env +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -56,6 +105,7 @@ func readManifest(t *testing.T) manifest { if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } + m.Environment = m.environmentOf(t) return m } @@ -83,26 +133,66 @@ func (m manifest) packages() (present, absent []string) { return present, absent } -// sameAsSource checks that a file resource's content is byte for byte the module's source file, so -// the readable file in the repository is what the machine gets. -func (m manifest) sameAsSource(t *testing.T, id, source string) { +// fromTemplate checks that a file resource takes its text from its template file in the module's +// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the +// template is there. The readable file in the repository is the one place the machine's text is written. +func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() - want, err := os.ReadFile(filepath.Join("..", "..", source)) - if err != nil { - t.Fatal(err) - } r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } - if got, _ := r["content"].(string); got != string(want) { - t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + if r["template"] != template { + t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) + } + if _, inline := r["content"]; inline { + t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) + } + if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { + t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } +// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs +// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func (m manifest) everyFileIsATemplate(t *testing.T) { + t.Helper() + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} + +// text is the text a file resource places on the machine: its template file's. +func (m manifest) text(t *testing.T, id string) string { + t.Helper() + name, _ := m.resource(t, id)["template"].(string) + if name == "" { + t.Fatalf("resource %s names no template", id) + } + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { diff --git a/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go index 3a585d68..3934c73b 100644 --- a/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go +++ b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go @@ -30,12 +30,12 @@ func TestTheColourBuildIsKeptWithAFallbackAndXscreensaverGoes(t *testing.T) { if !reflect.DeepEqual(present, []string{"xss-lock", "i3lock"}) || !reflect.DeepEqual(absent, []string{"xscreensaver"}) { t.Fatalf("packages: %v, absent %v", present, absent) } - m.sameAsSource(t, "wrapper", "files/bin/screen-lock") + m.fromTemplate(t, "wrapper", "files/bin/screen-lock") wrapper := m.resource(t, "wrapper") if wrapper["mode"] != "0755" || wrapper["path"] != "${machine:account-home}/.local/bin/screen-lock" { t.Fatalf("the locker: %v", wrapper) } - c := wrapper["content"].(string) + c := m.text(t, "wrapper") // The colour build's options only once it has said it is the colour build: the distribution's // i3lock refuses an option it does not know, and the screen would not lock at all. check := strings.Index(c, `i3lock --version 2>&1 | grep -qE '[0-9]\.c\.[0-9]'`) @@ -53,10 +53,10 @@ func TestTheColourBuildIsKeptWithAFallbackAndXscreensaverGoes(t *testing.T) { func TestTheSessionStartSetsTheTimeoutsAndKeepsOneWatcherForTheSession(t *testing.T) { m := readManifest(t) - if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" { - t.Fatalf("one xinitrc contribution in normal: %+v", m.Shell) + if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "normal" { + t.Fatalf("one xinitrc contribution in normal: %+v", m.shell(t)) } - code := m.Shell[0].Code + code := m.shell(t)[0].Code if strings.Count(code, "xss-lock --") != 1 || !strings.Contains(code, "--transfer-sleep-lock") || !strings.Contains(code, "while kill -0 $$") || !strings.HasSuffix(strings.TrimSpace(code), "&") { t.Fatalf("the watcher: %q", code) @@ -87,3 +87,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } + +// No file resource carries its text inline (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + readManifest(t).everyFileIsATemplate(t) +} diff --git a/modules/screen-lock/files/xinitrc-normal b/modules/screen-lock/files/xinitrc-normal new file mode 100644 index 00000000..f7f7123e --- /dev/null +++ b/modules/screen-lock/files/xinitrc-normal @@ -0,0 +1,8 @@ +# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle, +# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on +# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and +# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the +# session's own process, which becomes the window manager). +xset s 1800 1800 +xset dpms 1800 1800 3600 +(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- "$HOME/.local/bin/screen-lock"; sleep 2; done) & diff --git a/modules/screen-lock/module.json b/modules/screen-lock/module.json index 28bf7e57..1791c798 100644 --- a/modules/screen-lock/module.json +++ b/modules/screen-lock/module.json @@ -1,6 +1,7 @@ { "module": "screen-lock", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -21,11 +22,11 @@ "screen_lock_inhibit", "screen_lock_locked" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,\n# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on\n# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and\n# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the\n# session's own process, which becomes the window manager).\nxset s 1800 1800\nxset dpms 1800 1800 3600\n(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- \"$HOME/.local/bin/screen-lock\"; sleep 2; done) &\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" } ], "resources": [ @@ -51,7 +52,7 @@ "path": "${machine:account-home}/.local/bin/screen-lock", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before\n# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.\n#\n# The operator's look, adopted from the predecessor's my-i3lock: the screen as it was, blurred, with\n# an orange ring, the time and the date. That needs i3lock-color, from the distribution's user\n# repository, kept as found until the mesh carries such software (novox/hq research 027, question 1).\n# On a machine without it the distribution's i3lock shows the same blurred screen, taken here, with\n# its own plain ring; failing that, black.\n#\n# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends\n# once it is released. The locker must not inherit it, or the machine would wait for the unlock\n# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is\n# xss-lock's own documented pattern for i3lock.\nset -u\n\n# One locker: a second press of the key, or a lock while locked, changes nothing.\nif pgrep -xu \"$EUID\" i3lock >/dev/null; then\n\texit 0\nfi\n\n# The colour build numbers its versions .c. (2.13.c.5); its version line never says \"color\".\nif i3lock --version 2>&1 | grep -qE '[0-9]\\.c\\.[0-9]'; then\n\tblank='#00000000' clear='#ffffff22' accent='#ca4a00' wrong='#880000bb' verifying='#bb00bbbb'\n\toptions=(\n\t\t--insidever-color=\"$clear\" --ringver-color=\"$verifying\"\n\t\t--insidewrong-color=\"$clear\" --ringwrong-color=\"$wrong\"\n\t\t--inside-color=\"$blank\" --ring-color=\"$accent\" --line-color=\"$blank\" --separator-color=\"$accent\"\n\t\t--verif-color=\"$accent\" --wrong-color=\"$accent\" --time-color=\"$accent\" --date-color=\"$accent\"\n\t\t--layout-color=\"$accent\" --keyhl-color=\"$wrong\" --bshl-color=\"$wrong\"\n\t\t--screen 1 --blur 5 --ring-width=7.0 --clock --indicator\n\t\t--time-str=\"%H:%M:%S\" --date-str=\"%A, %Y-%m-%d\"\n\t\t--time-font=sans-serif --date-font=sans-serif --verif-font=sans-serif\n\t\t--wrong-font=sans-serif --layout-font=sans-serif --keylayout 1\n\t\t--show-failed-attempts --ignore-empty-password\n\t)\nelse\n\toptions=(--color=000000 --show-failed-attempts --ignore-empty-password)\n\tshot=\"${XDG_RUNTIME_DIR:-/tmp}/screen-lock.png\"\n\tif command -v magick >/dev/null && magick import -window root -resize 25% -blur 0x3 -resize 400% \"$shot\" 2>/dev/null; then\n\t\toptions+=(--image=\"$shot\")\n\tfi\nfi\n\nif [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then\n\tkill_i3lock() { pkill -xu \"$EUID\" \"$@\" i3lock; }\n\ttrap kill_i3lock TERM INT\n\ti3lock \"${options[@]}\" {XSS_SLEEP_LOCK_FD}<&-\n\texec {XSS_SLEEP_LOCK_FD}<&-\n\twhile kill_i3lock -0; do\n\t\tsleep 0.5\n\tdone\nelse\n\ttrap 'kill %%' TERM INT\n\ti3lock --nofork \"${options[@]}\" &\n\twait\nfi\n" + "template": "files/bin/screen-lock" } ], "build": { @@ -66,6 +67,11 @@ "loads": [ "screen-lock-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/searxng/files/config.json b/modules/searxng/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/searxng/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/searxng/files/settings.yml b/modules/searxng/files/settings.yml new file mode 100644 index 00000000..6d7248ee --- /dev/null +++ b/modules/searxng/files/settings.yml @@ -0,0 +1,16 @@ +{ + "use_default_settings": true, + "server": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + }, + "search": { + "formats": ["html", "json"] + }, + "valkey": { + "url": "valkey://valkey:6379/0" + } +} diff --git a/modules/searxng/module.json b/modules/searxng/module.json index f6951bdf..01d40126 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -29,6 +29,79 @@ } ] }, + "config": { + "general": { + "type": "object", + "description": "SearXNG's general section of settings.yml, laid over the file's own and SearXNG's defaults: the instance's name, contact and debug switches.", + "trusted": true, + "default": {} + }, + "brand": { + "type": "object", + "description": "SearXNG's brand section of settings.yml, laid over the file's own and SearXNG's defaults: the links the pages show for the instance's source, issues and documentation.", + "trusted": true, + "default": {} + }, + "search": { + "type": "object", + "description": "SearXNG's search section of settings.yml, laid over the file's own and SearXNG's defaults: safe search, autocomplete, the result formats offered and how long an engine is suspended after an error.", + "trusted": true, + "default": { + "formats": [ + "html", + "json" + ] + } + }, + "server": { + "type": "object", + "description": "SearXNG's server section of settings.yml, laid over the file's own and SearXNG's defaults: the public base URL, the request method, the limiter and the image proxy; its secret_key is the vault's.", + "trusted": true, + "default": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + } + }, + "ui": { + "type": "object", + "description": "SearXNG's ui section of settings.yml, laid over the file's own and SearXNG's defaults: the theme, the default locale and how results are shown.", + "trusted": true, + "default": {} + }, + "outgoing": { + "type": "object", + "description": "SearXNG's outgoing section of settings.yml, laid over the file's own and SearXNG's defaults: how SearXNG reaches the engines: timeouts, connection pool and proxies.", + "trusted": true, + "default": {} + }, + "engines": { + "type": "list", + "description": "SearXNG's engines section of settings.yml, laid over the file's own and SearXNG's defaults: the engines to add or change, each by its name.", + "trusted": true, + "default": [] + }, + "plugins": { + "type": "object", + "description": "SearXNG's plugins section of settings.yml, laid over the file's own and SearXNG's defaults: the plugins to switch on or off, by their id.", + "trusted": true, + "default": {} + }, + "categories_as_tabs": { + "type": "object", + "description": "SearXNG's categories_as_tabs section of settings.yml, laid over the file's own and SearXNG's defaults: the categories shown as tabs above the results.", + "trusted": true, + "default": {} + }, + "preferences": { + "type": "object", + "description": "SearXNG's preferences section of settings.yml, laid over the file's own and SearXNG's defaults: the preferences a user cannot change (lock).", + "trusted": true, + "default": {} + } + }, "resources": [ { "id": "mesh-state", @@ -76,7 +149,7 @@ "path": "${dir:state}/settings.yml", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "general", "brand", "search", @@ -88,7 +161,7 @@ "categories_as_tabs", "preferences" ], - "content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n" + "template": "files/settings.yml" }, { "id": "server", @@ -111,7 +184,7 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n" + "template": "files/config.json" } ], "requires": [ @@ -142,6 +215,11 @@ "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", "MESH_SEARXNG_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/showcase/module.json b/modules/showcase/module.json index ab627311..9f9e9812 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -1,6 +1,7 @@ { "module": "showcase", "version": "1", + "templates": "templates", "slug": "show", "capabilities": [ "container-runtime" @@ -74,6 +75,11 @@ "name": "helper", "kind": "upstream", "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] }, @@ -108,7 +114,7 @@ "type": "file", "path": "${dir:state}/showcase.env", "mode": "0600", - "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" + "template": "templates/showcase.env" }, { "id": "packed", diff --git a/modules/showcase/templates/showcase.env b/modules/showcase/templates/showcase.env new file mode 100644 index 00000000..8ea4a902 --- /dev/null +++ b/modules/showcase/templates/showcase.env @@ -0,0 +1,5 @@ +SHOWCASE_GREETING=hello +SHOWCASE_EVERY_SECONDS=30 +SHOWCASE_STATE=/var/lib/showcase +SHOWCASE_DATABASE=${bound:postgres-database:at} +SHOWCASE_LISTEN=${port:8080} diff --git a/modules/slack/cmd/slack-tools/manifest_test.go b/modules/slack/cmd/slack-tools/manifest_test.go index b3a90e5d..01c43d74 100644 --- a/modules/slack/cmd/slack-tools/manifest_test.go +++ b/modules/slack/cmd/slack-tools/manifest_test.go @@ -24,11 +24,13 @@ type manifest struct { Claims []any `json:"claims"` Seats []any `json:"seats"` Shell []any `json:"shell"` - Contributions []struct { - Seat string `json:"seat"` - Kind string `json:"kind"` - Content string `json:"content"` - } `json:"contributions"` + Moved bool `json:"moved"` + Contributions []any `json:"contributions"` + Items []struct { + Collection string `json:"collection"` + Name string `json:"name"` + Template string `json:"template"` + } `json:"items"` Environment any `json:"environment"` Build struct { Artifacts []map[string]any `json:"artifacts"` @@ -72,15 +74,12 @@ func TestItInstallsNothingAndOwnsNoFile(t *testing.T) { func TestTheOneContributionIsTheStartAndTheWindowRules(t *testing.T) { m, _ := readManifest(t) - if len(m.Contributions) != 1 { - t.Fatalf("%+v", m.Contributions) - } - c := m.Contributions[0] - if c.Seat != "node-display-session" || c.Kind != "config" || !strings.HasSuffix(c.Content, "\n") { - t.Fatalf("%+v", c) + content := displayLines(t, m) + if !strings.HasSuffix(content, "\n") { + t.Fatalf("%q", content) } var execs, rules []string - for _, l := range strings.Split(c.Content, "\n") { + for _, l := range strings.Split(content, "\n") { l = strings.TrimSpace(l) switch { case strings.HasPrefix(l, "exec"): @@ -112,7 +111,7 @@ func TestTheOneContributionIsTheStartAndTheWindowRules(t *testing.T) { } // The placed lines start Slack once: the fake i3 configuration below holds exactly this content. f := newFake(t) - f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+c.Content) + f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+content) if s := f.starts(); len(s) != 1 || !s[0].Mine { t.Fatalf("%+v", s) } @@ -139,7 +138,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { for name := range served { t.Errorf("the bundle serves %s, which module.json does not list", name) } - if len(m.Build.Artifacts) != 1 { + if len(m.Build.Artifacts) != 2 { // the tools bundle and the archive of its template t.Fatalf("%v", m.Build.Artifacts) } b := m.Build.Artifacts[0] @@ -154,3 +153,18 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { } } } + +// displayLines is the module's lines for the window manager: the text of the template of its one item of the +// collection contribution:node-display-session:config (design 57 L3, issue 848). +func displayLines(t *testing.T, m manifest) string { + t.Helper() + if !m.Moved || len(m.Contributions) != 0 || len(m.Items) != 1 || + m.Items[0].Collection != "contribution:node-display-session:config" { + t.Fatalf("moved %v, contributions %v, items %+v", m.Moved, m.Contributions, m.Items) + } + text, err := os.ReadFile(filepath.Join("..", "..", m.Items[0].Template)) + if err != nil { + t.Fatal(err) + } + return string(text) +} diff --git a/modules/slack/cmd/slack-tools/slack_test.go b/modules/slack/cmd/slack-tools/slack_test.go index 15837ea9..04296b5f 100644 --- a/modules/slack/cmd/slack-tools/slack_test.go +++ b/modules/slack/cmd/slack-tools/slack_test.go @@ -109,7 +109,7 @@ func newSlack(t *testing.T) (*fake, map[string]string) { func contribution(t *testing.T) string { m, _ := readManifest(t) - return m.Contributions[0].Content + return displayLines(t, m) } func noSecrets(t *testing.T, v any) string { diff --git a/modules/slack/files/display-session b/modules/slack/files/display-session new file mode 100644 index 00000000..01660f84 --- /dev/null +++ b/modules/slack/files/display-session @@ -0,0 +1,11 @@ +# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push. +# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry. +# No XDG autostart entry starts it as well; slack_check names one if it appears. +exec --no-startup-id /usr/bin/slack --gtk-version=3 -s +# The chat window. Slack owns four X windows, and the only one i3 manages has the class "slack" in +# lower case; the three of class "Slack" (the packaged entry's StartupWMClass) are its unmanaged +# helpers and its tray icon, so a rule on class="Slack" matches nothing. window_role keeps a call or +# screen-share window out of the rules. $ws3 is i3's, in scope here. +for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3 +for_window [class="(?i)^slack$" window_role="browser-window"] floating disable +for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2 diff --git a/modules/slack/module.json b/modules/slack/module.json index b0b6d760..9feeedb3 100644 --- a/modules/slack/module.json +++ b/modules/slack/module.json @@ -1,6 +1,7 @@ { "module": "slack", "version": "1", + "moved": true, "requires": [ "x11-display" ], @@ -22,14 +23,19 @@ "loads": [ "slack-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" } ] } diff --git a/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go b/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go index 99f26865..7100c3a6 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/ssh_test.go @@ -8,6 +8,7 @@ import ( "reflect" "sort" "strings" + "sync" "testing" "time" ) @@ -28,12 +29,22 @@ type call struct { type fake struct { answer func(c call) Ran + mu sync.Mutex calls []call } +// recorded returns a copy of the calls so far; scans call run from parallel goroutines. +func (f *fake) recorded() []call { + f.mu.Lock() + defer f.mu.Unlock() + return append([]call(nil), f.calls...) +} + func (f *fake) run(_ context.Context, env []string, name string, args ...string) Ran { c := call{env, name, args} + f.mu.Lock() f.calls = append(f.calls, c) + f.mu.Unlock() if f.answer == nil { return Ran{Status: 1} } @@ -152,7 +163,7 @@ func TestKeysAreDescribedByTheirPublicHalfAndAPassphraseIsAskedNotRead(t *testin if k := by["stray"]; !k.PublicMissing || k.OfferedBy != "" || k.Fingerprint != edFP { t.Errorf("stray: %+v", k) } - for _, c := range f.calls { + for _, c := range f.recorded() { if c.name == "ssh-keygen" && c.args[0] == "-y" && !reflect.DeepEqual(c.args[:3], []string{"-y", "-P", ""}) { t.Errorf("a passphrase check could prompt: %v", c.args) } @@ -263,7 +274,7 @@ func TestKnownHostComparesWhatIsKnownWithWhatIsOffered(t *testing.T) { t.Fatalf("not appended: %s", after) } sawRemove := false - for _, c := range f.calls { + for _, c := range f.recorded() { if c.name == "ssh-keygen" && reflect.DeepEqual(c.args[:2], []string{"-R", "ace.internal"}) { sawRemove = true } @@ -303,7 +314,7 @@ func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) { if err != nil || got["ok"] != true || got["method"] != "publickey" || got["connected_to"] != "10.0.0.2:22" { t.Fatalf("%v %v", got, err) } - args := strings.Join(f.calls[0].args, " ") + args := strings.Join(f.recorded()[0].args, " ") if !strings.Contains(args, "BatchMode=yes") || !strings.Contains(args, "StrictHostKeyChecking=yes") || !strings.HasSuffix(args, "ace true") { t.Fatalf("not a batch test: %s", args) } @@ -388,7 +399,7 @@ func TestTheManifestsRegionIsWhatSshReads(t *testing.T) { var region string for _, r := range m.Resources { if r["id"] == "config" { - region = r["content"].(string) + region = templateText(t, r) if r["into"] != "block" || r["at"] != "start" { t.Fatalf("the region is not written into the start: %v", r) } diff --git a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/ssh-client/files/config b/modules/ssh-client/files/config new file mode 100644 index 00000000..dac07fb6 --- /dev/null +++ b/modules/ssh-client/files/config @@ -0,0 +1,6 @@ +# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh +# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order: +# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at +# every push. Everything below it is yours, kept as you wrote it, and applies to every host the +# files above leave unsettled. +Include ~/.ssh/config.d/* diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index e6ac8f1a..5bc3c2e2 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -44,7 +44,7 @@ "mode": "0600", "into": "block", "at": "start", - "content": "# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh\n# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order:\n# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at\n# every push. Everything below it is yours, kept as you wrote it, and applies to every host the\n# files above leave unsettled.\nInclude ~/.ssh/config.d/*\n" + "template": "files/config" } ], "facts": { @@ -66,6 +66,11 @@ "loads": [ "ssh-client-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/sshd/files/10-mesh.conf b/modules/sshd/files/10-mesh.conf new file mode 100644 index 00000000..86cd883a --- /dev/null +++ b/modules/sshd/files/10-mesh.conf @@ -0,0 +1,10 @@ +# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead. +Port 22 +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +X11Forwarding no +PrintMotd no +AcceptEnv LANG LC_* diff --git a/modules/sshd/module.json b/modules/sshd/module.json index a675636b..066e058d 100644 --- a/modules/sshd/module.json +++ b/modules/sshd/module.json @@ -29,7 +29,7 @@ "type": "file", "path": "/etc/ssh/sshd_config.d/10-mesh.conf", "mode": "0644", - "content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n" + "template": "files/10-mesh.conf" }, { "id": "run", @@ -41,5 +41,14 @@ "config" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/step-ca/files/config.json b/modules/step-ca/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/modules/step-ca/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/step-ca/files/init.env b/modules/step-ca/files/init.env new file mode 100644 index 00000000..31d0896c --- /dev/null +++ b/modules/step-ca/files/init.env @@ -0,0 +1,2 @@ +DOCKER_STEPCA_INIT_PASSWORD=${secret:password} +DOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1 diff --git a/modules/step-ca/module.json b/modules/step-ca/module.json index a391a8b4..ebb587db 100644 --- a/modules/step-ca/module.json +++ b/modules/step-ca/module.json @@ -57,7 +57,8 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0644", - "content": "{}", + "template": "files/config.json", + "takes-config": [], "merge": "json" }, { @@ -65,7 +66,7 @@ "type": "file", "path": "${dir:mesh-state}/init.env", "mode": "0600", - "content": "DOCKER_STEPCA_INIT_PASSWORD=${secret:password}\nDOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1\n" + "template": "files/init.env" }, { "id": "server", @@ -90,5 +91,14 @@ ], "secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/modules/sudo/cmd/sudo-tools/manifest_test.go b/modules/sudo/cmd/sudo-tools/manifest_test.go index aba08c85..3d3bb454 100644 --- a/modules/sudo/cmd/sudo-tools/manifest_test.go +++ b/modules/sudo/cmd/sudo-tools/manifest_test.go @@ -34,7 +34,7 @@ func TestItDeclaresThePackageAndTheDropInSudoReads(t *testing.T) { } func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) { - content := manifest(t).resource(t, "operator")["content"].(string) + content := templateText(t, manifest(t).resource(t, "operator")) var rules []string for _, l := range strings.Split(content, "\n") { if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") { diff --git a/modules/sudo/cmd/sudo-tools/templates_test.go b/modules/sudo/cmd/sudo-tools/templates_test.go new file mode 100644 index 00000000..7d0718b5 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/templates_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The module's directory, where its manifest and its files/ are. +const moduleDir = "../.." + +// templateText is the text a file resource places on the machine: its template's, in the module's files/ +// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. +func templateText(t *testing.T, r map[string]any) string { + t.Helper() + name, _ := r["template"].(string) + if name == "" { + t.Fatalf("resource %v names no template", r["id"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("resource %v names %s: %v", r["id"], name, err) + } + return string(raw) +} + +// No file resource carries its text inline: each names its template in files/, and the build packs files/ +// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). +func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if _, inline := r["content"]; inline { + t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) + } + if r["type"] == "file" { + templateText(t, r) + } + } + packs := 0 + for _, a := range m.Build.Artifacts { + if a["kind"] == "archive" && a["from"] == "files" { + packs++ + if a["name"] != "files" { + t.Errorf("the templates archive is named %v, not files", a["name"]) + } + } + } + if packs != 1 { + t.Errorf("the build packs files/ into %d archives, not one", packs) + } +} diff --git a/modules/sudo/files/10-mesh-operator b/modules/sudo/files/10-mesh-operator new file mode 100644 index 00000000..997a49a6 --- /dev/null +++ b/modules/sudo/files/10-mesh-operator @@ -0,0 +1,9 @@ +# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates +# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it; +# until this file, every machine said so only in a line set by hand in /etc/sudoers. +# Written whole at every push: an edit here is overwritten. A file of this directory whose name +# holds a dot or ends in ~ is not read by sudo; this name holds neither. +# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator +# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI). +Defaults use_pty +${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/modules/sudo/module.json b/modules/sudo/module.json index 386deb14..364ce5fc 100644 --- a/modules/sudo/module.json +++ b/modules/sudo/module.json @@ -21,7 +21,7 @@ "type": "file", "path": "/etc/sudoers.d/10-mesh-operator", "mode": "0440", - "content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator\n# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI).\nDefaults use_pty\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n" + "template": "files/10-mesh-operator" } ], "build": { @@ -36,6 +36,11 @@ "loads": [ "sudo-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/modules/supabase/files/analytics-env.env b/modules/supabase/files/analytics-env.env new file mode 100644 index 00000000..b1b877f0 --- /dev/null +++ b/modules/supabase/files/analytics-env.env @@ -0,0 +1,14 @@ +LOGFLARE_NODE_HOST=127.0.0.1 +DB_USERNAME=supabase_admin +DB_DATABASE=_supabase +DB_HOSTNAME=supabase-db +DB_PORT=5432 +DB_PASSWORD=${secret:postgres} +DB_SCHEMA=_analytics +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_SINGLE_TENANT=true +LOGFLARE_SUPABASE_MODE=true +LOGFLARE_MIN_CLUSTER_SIZE=1 +POSTGRES_BACKEND_URL=postgresql://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +POSTGRES_BACKEND_SCHEMA=_analytics +LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true diff --git a/modules/supabase/files/analytics-reconcile.exs b/modules/supabase/files/analytics-reconcile.exs new file mode 100644 index 00000000..179776e0 --- /dev/null +++ b/modules/supabase/files/analytics-reconcile.exs @@ -0,0 +1,86 @@ +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# logflare 1.4.0 copies two of its settings into its own database once and never reads them again: +# LOGFLARE_API_KEY becomes the default user's api_key when that user is created, and +# POSTGRES_BACKEND_URL becomes each source's backend when that source is created. A changed value +# reaches neither: a rotated key is refused, and a moved database is never written to (every +# source here pointed at db:5432, a name that resolves nowhere, so nothing was stored). +# +# Run by the start script before logflare starts, through logflare's own code (its Repo, its Users +# and Backends contexts and their changesets): it makes both copies say what the environment says. +# It changes nothing when they already do, says what it changed, never prints the key or a URL's +# password, and stops the start when it cannot finish. +fail = fn why -> + IO.puts(:stderr, "analytics: #{why}; not starting") + System.halt(1) +end + +say = fn what -> IO.puts(:stderr, "analytics: #{what}") end + +key = System.get_env("LOGFLARE_API_KEY") +url = System.get_env("POSTGRES_BACKEND_URL") +schema = System.get_env("POSTGRES_BACKEND_SCHEMA") + +if key in [nil, ""], do: fail.("LOGFLARE_API_KEY is not set, so the key logflare must accept is unknown") +if url in [nil, ""], do: fail.("POSTGRES_BACKEND_URL is not set, so where logflare must store logs is unknown") + +where = fn u -> + case URI.parse(to_string(u)) do + %URI{host: host, port: port, path: path} when is_binary(host) -> "#{host}:#{port}#{path}" + _ -> "an unreadable URL" + end +end + +Application.ensure_all_started(:ssl) + +outcome = + Ecto.Migrator.with_repo(Logflare.Repo, fn _repo -> + # Backends.update_source_backend_config restarts the source's pipeline after saving, and looks + # it up in this registry to do so; logflare is not running yet, so the registry is empty and + # there is nothing to restart. + {:ok, _} = Registry.start_link(keys: :unique, name: Logflare.Backends.SourceRegistry) + + case Logflare.SingleTenant.get_default_user() do + nil -> + say.("no default user yet; logflare creates it from LOGFLARE_API_KEY and POSTGRES_BACKEND_URL as it starts") + :ok + + user -> + if user.api_key == key and is_nil(user.old_api_key) do + say.("the default user's API key is LOGFLARE_API_KEY already") + else + # old_api_key is cleared too: logflare's UI can swap it back in, and the key replaced + # here may be one that leaked. + case Logflare.Users.update_user_all_fields(user, %{api_key: key, old_api_key: nil}) do + {:ok, _} -> say.("the default user's API key was replaced with LOGFLARE_API_KEY") + {:error, changeset} -> fail.("the default user's API key could not be replaced: #{inspect(changeset.errors)}") + end + end + + results = + for source <- Logflare.Sources.list_sources_by_user(user), + backend <- Logflare.Backends.list_source_backends(source), + backend.type == :postgres do + if backend.config.url == url and backend.config.schema == schema do + :same + else + case Logflare.Backends.update_source_backend_config(backend, %{url: url, schema: schema}) do + {:ok, _} -> + say.("source #{source.name} stored logs at #{where.(backend.config.url)} schema #{inspect(backend.config.schema)}; now at #{where.(url)} schema #{inspect(schema)}") + + {:error, changeset} -> + fail.("source #{source.name}'s backend could not be pointed at #{where.(url)}: #{inspect(changeset.errors)}") + end + end + end + + say.("#{Enum.count(results, &(&1 == :same))} source backend(s) already at #{where.(url)}") + + :ok + end + end) + +case outcome do + {:ok, :ok, _} -> :ok + other -> fail.("logflare's database could not be reconciled: #{inspect(other)}") +end diff --git a/modules/supabase/files/analytics-start.sh b/modules/supabase/files/analytics-start.sh new file mode 100644 index 00000000..bf39892b --- /dev/null +++ b/modules/supabase/files/analytics-start.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# Two things logflare 1.4.0 does not do for itself, done here before it starts; either failing +# stops the start, with the reason as the last line of this container's log. +# +# 1. The API key never travels in a URL. logflare prints the whole URL of every request that fails, +# query string included, in the error report Plug.Cowboy writes ("Request: POST +# /api/logs?source_name=...&api_key=..."). That is an error, so no log level hides it while errors +# are logged at all (novox/hq issue 268). logflare reads the key from the x-api-key header as well, +# and the mesh's caller, vector, sends it there; this refuses to start logflare while the vector +# config it is given still passes the key in a URL: an analytics that does not start says why +# here, and one that leaks says nothing. +# +# 2. Its database says what its environment says. logflare copies LOGFLARE_API_KEY into its default +# user and POSTGRES_BACKEND_URL into every source's backend once, when it creates them, and never +# again; reconcile.exs makes both copies match the environment through logflare's own code, after +# its migrations and before it starts. That is what lets the mesh rotate the key by starting +# logflare again, and what moves the sources' backend when the database's address or password +# changes. +set -e +conf=/run/logflare/vector.yml +if [ ! -r "$conf" ]; then + echo "analytics: $conf is not there to check, so whether the API key travels in a URL is unknown; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +if grep -v '^[[:space:]]*#' "$conf" | grep -q 'api_key='; then + echo "analytics: $conf passes the API key in a URL, and logflare prints a failed request's URL; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +reconcile=/run/logflare/reconcile.exs +if [ ! -r "$reconcile" ]; then + echo "analytics: $reconcile is not there, so whether logflare's stored key and backends match its environment is unknown; not starting" >&2 + exit 1 +fi +cd /opt/app/rel/logflare/bin +# The tables reconcile.exs reads exist only once the migrations have run; run.sh runs them again, +# which finds nothing left to do. +./logflare eval Logflare.Release.migrate +./logflare eval 'Code.eval_file("/run/logflare/reconcile.exs")' +exec sh run.sh diff --git a/modules/supabase/files/db-init/jwt.sql b/modules/supabase/files/db-init/jwt.sql new file mode 100644 index 00000000..90d88222 --- /dev/null +++ b/modules/supabase/files/db-init/jwt.sql @@ -0,0 +1,2 @@ +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO '${secret:jwt}'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600'; diff --git a/modules/supabase/files/db-init/logs.sql b/modules/supabase/files/db-init/logs.sql new file mode 100644 index 00000000..255c0f40 --- /dev/null +++ b/modules/supabase/files/db-init/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/modules/supabase/files/db-init/pooler.sql b/modules/supabase/files/db-init/pooler.sql new file mode 100644 index 00000000..162c5b96 --- /dev/null +++ b/modules/supabase/files/db-init/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/modules/supabase/files/db-init/realtime.sql b/modules/supabase/files/db-init/realtime.sql new file mode 100644 index 00000000..4d4b9ffb --- /dev/null +++ b/modules/supabase/files/db-init/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/modules/supabase/files/db-init/roles.sql b/modules/supabase/files/db-init/roles.sql new file mode 100644 index 00000000..2a05c0b2 --- /dev/null +++ b/modules/supabase/files/db-init/roles.sql @@ -0,0 +1,7 @@ +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/modules/supabase/files/db-init/supabase.sql b/modules/supabase/files/db-init/supabase.sql new file mode 100644 index 00000000..6236ae1b --- /dev/null +++ b/modules/supabase/files/db-init/supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/modules/supabase/files/db-init/webhooks.sql b/modules/supabase/files/db-init/webhooks.sql new file mode 100644 index 00000000..5837b861 --- /dev/null +++ b/modules/supabase/files/db-init/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/modules/supabase/files/functions-env.env b/modules/supabase/files/functions-env.env new file mode 100644 index 00000000..5eade5e6 --- /dev/null +++ b/modules/supabase/files/functions-env.env @@ -0,0 +1,6 @@ +JWT_SECRET=${secret:jwt} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_ROLE_KEY=${secret:service-role-key} +SUPABASE_DB_URL=postgresql://postgres:${secret:postgres}@supabase-db:5432/postgres +VERIFY_JWT=false diff --git a/modules/supabase/files/functions-main/index.ts b/modules/supabase/files/functions-main/index.ts new file mode 100644 index 00000000..a094010b --- /dev/null +++ b/modules/supabase/files/functions-main/index.ts @@ -0,0 +1,94 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function verifyJWT(jwt: string): Promise { + const encoder = new TextEncoder() + const secretKey = encoder.encode(JWT_SECRET) + try { + await jose.jwtVerify(jwt, secretKey) + } catch (err) { + console.error(err) + return false + } + return true +} + +serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await verifyJWT(token) + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/modules/supabase/files/gotrue.env b/modules/supabase/files/gotrue.env new file mode 100644 index 00000000..f29f778a --- /dev/null +++ b/modules/supabase/files/gotrue.env @@ -0,0 +1,28 @@ +GOTRUE_API_HOST=0.0.0.0 +GOTRUE_API_PORT=9999 +API_EXTERNAL_URL=https://${bound:route:name} +GOTRUE_DB_DRIVER=postgres +GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres +GOTRUE_SITE_URL=https://${bound:route:name} +GOTRUE_URI_ALLOW_LIST= +GOTRUE_DISABLE_SIGNUP=true +GOTRUE_JWT_ADMIN_ROLES=service_role +GOTRUE_JWT_AUD=authenticated +GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated +GOTRUE_JWT_EXP=3600 +GOTRUE_JWT_SECRET=${secret:jwt} +GOTRUE_EXTERNAL_EMAIL_ENABLED=false +GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_SMTP_ADMIN_EMAIL=admin@example.com +GOTRUE_SMTP_HOST=supabase-mail +GOTRUE_SMTP_PORT=2500 +GOTRUE_SMTP_USER=fake_mail_user +GOTRUE_SMTP_PASS=fake_mail_password +GOTRUE_SMTP_SENDER_NAME=fake_sender +GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify +GOTRUE_EXTERNAL_PHONE_ENABLED=false +GOTRUE_SMS_AUTOCONFIRM=true diff --git a/modules/supabase/files/kong.yml b/modules/supabase/files/kong.yml new file mode 100644 index 00000000..e13bdb24 --- /dev/null +++ b/modules/supabase/files/kong.yml @@ -0,0 +1,242 @@ +# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in. +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: "${secret:anon-key}" + - username: service_role + keyauth_credentials: + - key: "${secret:service-role-key}" + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: "${secret:dashboard-user}" + password: "${secret:dashboard}" + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + url: http://supabase-auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + url: http://supabase-auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + url: http://supabase-auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://supabase-auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure REST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://supabase-rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://supabase-rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Storage routes: the storage server manages its own auth + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://supabase-storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://supabase-edge-functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## Analytics routes + - name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://supabase-analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://supabase-meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://supabase-studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/modules/supabase/files/meta-env.env b/modules/supabase/files/meta-env.env new file mode 100644 index 00000000..c9d25096 --- /dev/null +++ b/modules/supabase/files/meta-env.env @@ -0,0 +1,6 @@ +PG_META_PORT=8080 +PG_META_DB_HOST=supabase-db +PG_META_DB_PORT=5432 +PG_META_DB_NAME=postgres +PG_META_DB_USER=supabase_admin +PG_META_DB_PASSWORD=${secret:postgres} diff --git a/modules/supabase/files/pooler-env.env b/modules/supabase/files/pooler-env.env new file mode 100644 index 00000000..f02c7cd7 --- /dev/null +++ b/modules/supabase/files/pooler-env.env @@ -0,0 +1,14 @@ +PORT=4000 +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_PASSWORD=${secret:postgres} +DATABASE_URL=ecto://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +CLUSTER_POSTGRES=true +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +VAULT_ENC_KEY=${secret:pooler-vault} +API_JWT_SECRET=${secret:jwt} +METRICS_JWT_SECRET=${secret:jwt} +REGION=local +ERL_AFLAGS=-proto_dist inet_tcp +RELEASE_NODE=supavisor@127.0.0.1 +POOLER_POOL_MODE=transaction diff --git a/modules/supabase/files/pooler.exs b/modules/supabase/files/pooler.exs new file mode 100644 index 00000000..61b5ba8c --- /dev/null +++ b/modules/supabase/files/pooler.exs @@ -0,0 +1,43 @@ +{:ok, _} = Application.ensure_all_started(:supavisor) + +# Written by the mesh (modules/supabase). Per-machine values come from settings.json (the module's +# one merge:json file); secrets come from the environment the image reads. +settings = "/etc/pooler/settings.json" |> File.read!() |> Jason.decode!() |> Map.get("pooler", %{}) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => to_string(settings["tenant"] || "default"), + "db_host" => "supabase-db", + "db_port" => System.get_env("POSTGRES_PORT"), + "db_database" => System.get_env("POSTGRES_DB"), + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => settings["max-client-connections"] || 100, + "default_pool_size" => settings["pool-size"] || 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => System.get_env("POSTGRES_PASSWORD"), + "mode_type" => System.get_env("POOLER_POOL_MODE"), + "pool_size" => settings["pool-size"] || 20, + "is_manager" => true + }] +} + +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> + {:ok, _} = Supavisor.Tenants.create_tenant(params) + + # A tenant made elsewhere (HAL named the database host "db") is pointed at this module's + # database container; nothing else it holds is changed. + %{db_host: host} = tenant when host != "supabase-db" -> + {:ok, _} = Supavisor.Tenants.update_tenant(tenant, %{"db_host" => "supabase-db"}) + + _ -> + :ok +end diff --git a/modules/supabase/files/postgrest.conf b/modules/supabase/files/postgrest.conf new file mode 100644 index 00000000..17557532 --- /dev/null +++ b/modules/supabase/files/postgrest.conf @@ -0,0 +1,7 @@ +db-uri = "postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres" +db-schemas = "public,storage,graphql_public" +db-anon-role = "anon" +jwt-secret = "${secret:jwt}" +db-use-legacy-gucs = false +app.settings.jwt_secret = "${secret:jwt}" +app.settings.jwt_exp = "3600" diff --git a/modules/supabase/files/realtime-env.env b/modules/supabase/files/realtime-env.env new file mode 100644 index 00000000..75e21369 --- /dev/null +++ b/modules/supabase/files/realtime-env.env @@ -0,0 +1,16 @@ +PORT=4000 +DB_HOST=supabase-db +DB_PORT=5432 +DB_USER=supabase_admin +DB_PASSWORD=${secret:postgres} +DB_NAME=postgres +DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime +DB_ENC_KEY=supabaserealtime +API_JWT_SECRET=${secret:jwt} +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +ERL_AFLAGS=-proto_dist inet_tcp +DNS_NODES='' +RLIMIT_NOFILE=10000 +APP_NAME=realtime +SEED_SELF_HOST=true +RUN_JANITOR=true diff --git a/modules/supabase/files/settings.json b/modules/supabase/files/settings.json new file mode 100644 index 00000000..8ab8cfa9 --- /dev/null +++ b/modules/supabase/files/settings.json @@ -0,0 +1,7 @@ +{ + "pooler": { + "tenant": "default", + "pool-size": 20, + "max-client-connections": 100 + } +} diff --git a/modules/supabase/files/storage-env.env b/modules/supabase/files/storage-env.env new file mode 100644 index 00000000..fd5da860 --- /dev/null +++ b/modules/supabase/files/storage-env.env @@ -0,0 +1,13 @@ +ANON_KEY=${secret:anon-key} +SERVICE_KEY=${secret:service-role-key} +POSTGREST_URL=http://supabase-rest:3000 +PGRST_JWT_SECRET=${secret:jwt} +DATABASE_URL=postgres://supabase_storage_admin:${secret:postgres}@supabase-db:5432/postgres +FILE_SIZE_LIMIT=52428800 +STORAGE_BACKEND=file +FILE_STORAGE_BACKEND_PATH=/var/lib/storage +TENANT_ID=stub +REGION=stub +GLOBAL_S3_BUCKET=stub +ENABLE_IMAGE_TRANSFORMATION=true +IMGPROXY_URL=http://supabase-imgproxy:5001 diff --git a/modules/supabase/files/studio-env.env b/modules/supabase/files/studio-env.env new file mode 100644 index 00000000..303dbc57 --- /dev/null +++ b/modules/supabase/files/studio-env.env @@ -0,0 +1,18 @@ +# Next.js listens on the address HOSTNAME names, and docker sets HOSTNAME to the container's id, +# so studio answered only on its network address while its image's health check asks localhost: +# unhealthy for ever while working. Every address, as the upstream compose file has it. +HOSTNAME=:: +STUDIO_PG_META_URL=http://supabase-meta:8080 +POSTGRES_PASSWORD=${secret:postgres} +DEFAULT_ORGANIZATION_NAME=Default Organization +DEFAULT_PROJECT_NAME=Default Project +OPENAI_API_KEY=${secret:openai} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_PUBLIC_URL=https://${bound:route:name} +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_KEY=${secret:service-role-key} +AUTH_JWT_SECRET=${secret:jwt} +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_URL=http://supabase-analytics:4000 +NEXT_PUBLIC_ENABLE_LOGS=true +NEXT_ANALYTICS_BACKEND_PROVIDER=postgres diff --git a/modules/supabase/files/vector.yml b/modules/supabase/files/vector.yml new file mode 100644 index 00000000..fb0f03d5 --- /dev/null +++ b/modules/supabase/files/vector.yml @@ -0,0 +1,258 @@ +# Written by the mesh (modules/supabase). +# +# Every sink hands logflare its API key in the x-api-key header, never as an api_key query parameter: +# logflare prints a failed request's whole URL, query string included, in its error report, so a +# key in the URL is a key in its log (novox/hq issue 268). supabase-analytics refuses to start +# while this file still puts the key in a URL. +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + include_containers: + - supabase-kong + - supabase-auth + - supabase-rest + - realtime-dev.supabase-realtime + - supabase-storage + - supabase-edge-functions + - supabase-db + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P